Skip to content

Commit d3510a6

Browse files
committed
infra: attestation preflight for agent/key-funder deploys
Add gh-backed image attestation verification to the deploy preflight for rust agents and key-funder. Surfaces build age (SLSA finishedOn) alongside image tag before prompting the user. - A successful verify prints the build timestamp + age (days/hours). - A failed verify prints a bold red banner warning about supply-chain risk. The deploy still prompts, but the prompt default flips to "no" so an accidental Enter aborts. - Build age is informational only; promotion-stage soak-time gates belong in release scripts.
1 parent 75e6066 commit d3510a6

3 files changed

Lines changed: 202 additions & 0 deletions

File tree

typescript/infra/scripts/agents/deploy-agents.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { confirm } from '@inquirer/prompts';
22
import chalk from 'chalk';
33
import { execSync } from 'child_process';
44

5+
import { DockerImageRepos } from '../../config/docker.js';
56
import { createAgentKeysIfNotExistsWithPrompt } from '../../src/agents/key-utils.js';
67
import { RootAgentConfig } from '../../src/config/agent/agent.js';
8+
import { preflightVerifyImages } from '../../src/utils/attestation.js';
79
import {
810
checkAgentImageExists,
911
checkMonorepoImageExists,
@@ -100,6 +102,40 @@ async function checkDockerTagsExist(agentConfig: RootAgentConfig) {
100102
}
101103
}
102104

105+
async function verifyAgentAttestationsWithPrompt(agentConfig: RootAgentConfig) {
106+
const refs = [
107+
{ component: 'scraper', tag: agentConfig.scraper?.docker.tag },
108+
{ component: 'validators', tag: agentConfig.validators?.docker.tag },
109+
{ component: 'relayer', tag: agentConfig.relayer?.docker.tag },
110+
]
111+
.filter((r): r is { component: string; tag: string } => !!r.tag)
112+
.map(({ component, tag }) => ({
113+
component,
114+
image: DockerImageRepos.AGENT,
115+
tag,
116+
}));
117+
118+
if (refs.length === 0) return;
119+
120+
console.log(chalk.grey.italic('Verifying image attestations...'));
121+
const { allVerified } = await preflightVerifyImages(refs);
122+
123+
const message = allVerified
124+
? 'All attestations verified. Continue with deploy?'
125+
: chalk.red.bold(
126+
'One or more images FAILED attestation verify. Continue with deploy anyway?',
127+
);
128+
129+
const shouldContinue = await confirm({
130+
message,
131+
default: allVerified,
132+
});
133+
if (!shouldContinue) {
134+
console.log(chalk.red.bold('Exiting...'));
135+
process.exit(1);
136+
}
137+
}
138+
103139
async function main() {
104140
// Note the create-keys script should be ran prior to running this script.
105141
// At the moment, `runAgentHelmCommand` has the side effect of creating keys / users
@@ -110,6 +146,7 @@ async function main() {
110146
// run the create-keys script first.
111147
const { agentConfig } = await getConfigsBasedOnArgs();
112148
await checkDockerTagsExist(agentConfig);
149+
await verifyAgentAttestationsWithPrompt(agentConfig);
113150

114151
await createAgentKeysIfNotExistsWithPrompt(agentConfig);
115152

typescript/infra/scripts/funding/deploy-key-funder.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { readFileSync } from 'fs';
44
import { join } from 'path';
55

66
import { Contexts } from '../../config/contexts.js';
7+
import { DockerImageRepos } from '../../config/docker.js';
78
import { KeyFunderHelmManager } from '../../src/funding/key-funder.js';
9+
import { preflightVerifyImages } from '../../src/utils/attestation.js';
810
import {
911
checkNodeServicesImageExists,
1012
warnIfPrTag,
@@ -41,6 +43,24 @@ async function main() {
4143
);
4244
process.exit(1);
4345
}
46+
47+
console.log(chalk.grey.italic('Verifying image attestation...'));
48+
const { allVerified } = await preflightVerifyImages([
49+
{ component: 'key-funder', image: DockerImageRepos.NODE_SERVICES, tag },
50+
]);
51+
52+
const shouldContinue = await confirm({
53+
message: allVerified
54+
? 'Attestation verified. Continue with deploy?'
55+
: chalk.red.bold(
56+
'Image FAILED attestation verify. Continue with deploy anyway?',
57+
),
58+
default: allVerified,
59+
});
60+
if (!shouldContinue) {
61+
console.log(chalk.red.bold('Exiting...'));
62+
process.exit(1);
63+
}
4464
}
4565

4666
const defaultRegistryCommit = readRegistryRc();
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import chalk from 'chalk';
2+
import { execSync } from 'child_process';
3+
4+
const DEFAULT_REPO = 'hyperlane-xyz/hyperlane-monorepo';
5+
6+
export type AttestationStatus =
7+
| {
8+
verified: true;
9+
finishedOn?: Date;
10+
ageMs?: number;
11+
}
12+
| {
13+
verified: false;
14+
reason: string;
15+
};
16+
17+
export interface ImageRef {
18+
component: string;
19+
image: string; // e.g. ghcr.io/hyperlane-xyz/hyperlane-agent
20+
tag: string;
21+
}
22+
23+
export async function verifyImageAttestation({
24+
image,
25+
tag,
26+
repo = DEFAULT_REPO,
27+
}: {
28+
image: string;
29+
tag: string;
30+
repo?: string;
31+
}): Promise<AttestationStatus> {
32+
const ref = `oci://${image}:${tag}`;
33+
try {
34+
const raw = execSync(
35+
`gh attestation verify ${ref} --repo ${repo} --format json`,
36+
{ stdio: ['ignore', 'pipe', 'pipe'] },
37+
).toString();
38+
39+
const finishedOn = extractFinishedOn(raw);
40+
if (!finishedOn) {
41+
return { verified: true };
42+
}
43+
return {
44+
verified: true,
45+
finishedOn,
46+
ageMs: Date.now() - finishedOn.getTime(),
47+
};
48+
} catch (err: unknown) {
49+
return { verified: false, reason: extractErrorMessage(err) };
50+
}
51+
}
52+
53+
function extractErrorMessage(err: unknown): string {
54+
if (typeof err !== 'object' || err === null) {
55+
return err instanceof Error ? err.message : 'unknown error';
56+
}
57+
const stderr = 'stderr' in err ? String(err.stderr ?? '').trim() : '';
58+
const stdout = 'stdout' in err ? String(err.stdout ?? '').trim() : '';
59+
const message = err instanceof Error ? err.message : '';
60+
return stderr || stdout || message || 'unknown error';
61+
}
62+
63+
function extractFinishedOn(rawJson: string): Date | undefined {
64+
try {
65+
const parsed = JSON.parse(rawJson);
66+
const entries = Array.isArray(parsed) ? parsed : [parsed];
67+
for (const entry of entries) {
68+
const statement = entry?.verificationResult?.statement ?? entry?.statement;
69+
const finishedOn =
70+
statement?.predicate?.runDetails?.metadata?.finishedOn ??
71+
statement?.predicate?.metadata?.buildFinishedOn;
72+
if (typeof finishedOn === 'string') {
73+
const d = new Date(finishedOn);
74+
if (!isNaN(d.getTime())) return d;
75+
}
76+
}
77+
} catch {
78+
// ignore - treated as "verified but age unknown"
79+
}
80+
return undefined;
81+
}
82+
83+
function formatAge(ms: number): string {
84+
const s = Math.floor(ms / 1000);
85+
const d = Math.floor(s / 86400);
86+
const h = Math.floor((s % 86400) / 3600);
87+
const m = Math.floor((s % 3600) / 60);
88+
if (d > 0) return `${d}d ${h}h`;
89+
if (h > 0) return `${h}h ${m}m`;
90+
return `${m}m`;
91+
}
92+
93+
export function printAttestationStatus(ref: ImageRef, status: AttestationStatus): void {
94+
const imageStr = `${ref.image}:${ref.tag}`;
95+
if (status.verified) {
96+
console.log(
97+
chalk.green(`✓ ${chalk.bold(ref.component)} attestation verified: ${imageStr}`),
98+
);
99+
if (status.finishedOn && status.ageMs != null) {
100+
console.log(
101+
chalk.gray(
102+
` built: ${status.finishedOn.toISOString()} (age: ${formatAge(status.ageMs)})`,
103+
),
104+
);
105+
} else {
106+
console.log(chalk.gray(' build age: unknown (could not parse provenance)'));
107+
}
108+
} else {
109+
const bar = '!'.repeat(72);
110+
console.log('');
111+
console.log(chalk.red.bold(bar));
112+
console.log(
113+
chalk.red.bold(`ATTESTATION VERIFY FAILED for ${ref.component} (${imageStr})`),
114+
);
115+
console.log(
116+
chalk.red.bold(
117+
'Image may not have been built by this repo\'s CI. Supply-chain risk.',
118+
),
119+
);
120+
console.log(chalk.red(`reason: ${status.reason.split('\n')[0]}`));
121+
console.log(chalk.red.bold(bar));
122+
console.log('');
123+
}
124+
}
125+
126+
export async function preflightVerifyImages(
127+
refs: ImageRef[],
128+
): Promise<{ allVerified: boolean; results: Array<{ ref: ImageRef; status: AttestationStatus }> }> {
129+
const seen = new Set<string>();
130+
const results: Array<{ ref: ImageRef; status: AttestationStatus }> = [];
131+
let allVerified = true;
132+
133+
for (const ref of refs) {
134+
const key = `${ref.image}:${ref.tag}`;
135+
if (seen.has(key)) continue;
136+
seen.add(key);
137+
138+
const status = await verifyImageAttestation({ image: ref.image, tag: ref.tag });
139+
printAttestationStatus(ref, status);
140+
results.push({ ref, status });
141+
if (!status.verified) allVerified = false;
142+
}
143+
144+
return { allVerified, results };
145+
}

0 commit comments

Comments
 (0)