Skip to content

Commit f249b00

Browse files
authored
Merge pull request #860 from durable-workflow/fix/release-platform-trust-manifests
Verify release trust using platform manifest digests
2 parents b34ef0b + 4cd2d29 commit f249b00

3 files changed

Lines changed: 116 additions & 14 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,29 @@ jobs:
272272
set -euo pipefail
273273
# GHSA-6q48-6c7w-9gw2: every published architecture's system, PHP,
274274
# Composer, Node and venv stores, checked offline in the image.
275+
[[ "$RELEASE_IMAGE" =~ @sha256:[0-9a-f]{64}$ ]]
276+
index_json="$(docker buildx imagetools inspect --raw "$RELEASE_IMAGE")"
275277
IFS=',' read -ra platforms <<<"$RELEASE_IMAGE_PLATFORMS"
278+
platform_images=()
276279
for platform in "${platforms[@]}"; do
277-
DOCKER_DEFAULT_PLATFORM="$platform" bash scripts/ci/pull-images.sh "$RELEASE_IMAGE"
280+
child_digest="$(jq -er --arg platform "$platform" '
281+
[.manifests[] | select(
282+
(.platform.os + "/" + .platform.architecture) == $platform and
283+
(.mediaType == "application/vnd.oci.image.manifest.v1+json" or
284+
.mediaType == "application/vnd.docker.distribution.manifest.v2+json"))]
285+
| if length == 1 and (.[0].digest | test("^sha256:[0-9a-f]{64}$"))
286+
then .[0].digest else error("Expected one image manifest for " + $platform) end
287+
' <<<"$index_json")"
288+
platform_images+=("${RELEASE_IMAGE%@*}@${child_digest}")
289+
done
290+
# A single local index reference cannot hold two platform images on
291+
# every Docker image store. Distinct verified child refs avoid that collision.
292+
for i in "${!platforms[@]}"; do
293+
platform="${platforms[$i]}"
294+
platform_image="${platform_images[$i]}"
295+
DOCKER_DEFAULT_PLATFORM="$platform" bash scripts/ci/pull-images.sh "$platform_image"
278296
docker run --rm --pull never --platform "$platform" --network none \
279-
--entrypoint server-verify-trust "$RELEASE_IMAGE"
297+
--entrypoint server-verify-trust "$platform_image"
280298
done
281299
282300
- name: Verify bare image first-run readiness

‎tests/Unit/ReleaseImageContinuationTest.php‎

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ final class ReleaseImageContinuationTest extends TestCase
1818

1919
private const DIGEST = 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
2020

21+
private const AMD64 = 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
22+
23+
private const ARM64 = 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
24+
2125
protected function setUp(): void
2226
{
2327
$this->directory = sys_get_temp_dir().'/release-continuation-'.bin2hex(random_bytes(8));
@@ -36,6 +40,12 @@ protected function setUp(): void
3640
echo '500 Internal Server Error' >&2
3741
exit 1
3842
fi
43+
store="$CALL_LOG-store-${3##*:}"
44+
if [ -f "$store" ] && [ "$(cat "$store")" != "$DOCKER_DEFAULT_PLATFORM" ]; then
45+
echo "cannot overwrite digest ${3##*@}" >&2
46+
exit 1
47+
fi
48+
printf '%s\n' "$DOCKER_DEFAULT_PLATFORM" > "$store"
3949
exit 0
4050
fi
4151
if [ "$1" = run ]; then
@@ -50,6 +60,11 @@ protected function setUp(): void
5060
test "$2" = imagetools
5161
if [ "$3" = create ]; then exit 0; fi
5262
test "$3" = inspect
63+
if [ "$4" = --raw ]; then
64+
test "$5" = "durableworkflow/server@$INDEX_DIGEST"
65+
printf '%s\n' "$INDEX_JSON"
66+
exit 0
67+
fi
5368
if [ "$4" = --format ]; then
5469
if [ "$STATE" = wrong_arm_config ]; then
5570
case "$5" in *linux/arm64*) printf '%s\n' "$BAD_METADATA"; exit 0 ;; esac
@@ -245,17 +260,7 @@ public static function trustPullOutcomes(): array
245260
#[DataProvider('trustPullOutcomes')]
246261
public function test_only_pulls_are_retried_and_trust_uses_the_cached_digest(string $state, int $status, int $pulls, int $runs): void
247262
{
248-
$workflow = Yaml::parse(file_get_contents(dirname(__DIR__, 2).'/.github/workflows/release.yml'));
249-
$steps = array_column($workflow['jobs']['publish']['steps'], null, 'name');
250-
$step = $steps['Verify published image trust stores'];
251-
$script = $this->directory.'/trust.sh';
252-
file_put_contents($script, $step['run']);
253-
$result = $this->runProcess(['bash', $script], [
254-
'STATE' => $state, 'RELEASE_IMAGE_PLATFORMS' => 'linux/amd64,linux/arm64',
255-
'RELEASE_IMAGE' => 'durableworkflow/server@'.self::DIGEST,
256-
'DW_PULL_ATTEMPTS' => $step['env']['DW_PULL_ATTEMPTS'] ?? '3',
257-
'DW_PULL_BACKOFF_SECONDS' => '0',
258-
]);
263+
$result = $this->runTrustStep(['STATE' => $state]);
259264
self::assertSame($status, $result['status'], $result['text']);
260265
self::assertSame($pulls, substr_count($result['calls'], 'pull --quiet'));
261266
self::assertSame($runs, substr_count($result['calls'], 'run --rm'));
@@ -269,6 +274,79 @@ public function test_only_pulls_are_retried_and_trust_uses_the_cached_digest(str
269274
}
270275
}
271276

277+
public function test_mock_reproduces_the_same_index_cross_platform_overwrite(): void
278+
{
279+
$result = $this->runProcess(['bash', '-c', <<<'SH'
280+
set -e
281+
DOCKER_DEFAULT_PLATFORM=linux/amd64 docker pull --quiet "$RELEASE_IMAGE"
282+
DOCKER_DEFAULT_PLATFORM=linux/arm64 docker pull --quiet "$RELEASE_IMAGE"
283+
SH], ['RELEASE_IMAGE' => 'durableworkflow/server@'.self::DIGEST]);
284+
self::assertSame(1, $result['status']);
285+
self::assertStringContainsString('cannot overwrite digest '.self::DIGEST, $result['text']);
286+
}
287+
288+
public function test_trust_runs_both_verified_children_without_overwriting_the_local_index(): void
289+
{
290+
$result = $this->runTrustStep();
291+
self::assertSame(0, $result['status'], $result['text']);
292+
self::assertStringContainsString('buildx imagetools inspect --raw durableworkflow/server@'.self::DIGEST, $result['calls']);
293+
foreach (['linux/amd64' => self::AMD64, 'linux/arm64' => self::ARM64] as $platform => $digest) {
294+
self::assertStringContainsString('pull --quiet durableworkflow/server@'.$digest, $result['calls']);
295+
self::assertStringContainsString('run --rm --pull never --platform '.$platform.' --network none --entrypoint server-verify-trust durableworkflow/server@'.$digest, $result['calls']);
296+
}
297+
self::assertStringNotContainsString('pull --quiet durableworkflow/server@'.self::DIGEST, $result['calls']);
298+
self::assertSame(2, substr_count($result['calls'], 'pull --quiet'));
299+
self::assertSame(2, substr_count($result['calls'], 'run --rm'));
300+
}
301+
302+
public static function invalidTrustIndexes(): iterable
303+
{
304+
$amd64 = ['mediaType' => 'application/vnd.oci.image.manifest.v1+json', 'digest' => self::AMD64,
305+
'platform' => ['os' => 'linux', 'architecture' => 'amd64']];
306+
$arm64 = ['mediaType' => 'application/vnd.oci.image.manifest.v1+json', 'digest' => self::ARM64,
307+
'platform' => ['os' => 'linux', 'architecture' => 'arm64']];
308+
yield 'missing amd64' => [[$arm64]];
309+
yield 'missing arm64' => [[$amd64]];
310+
yield 'wrong operating system' => [[$amd64, array_replace($arm64, ['platform' => ['os' => 'windows', 'architecture' => 'arm64']])]];
311+
yield 'wrong architecture' => [[$amd64, array_replace($arm64, ['platform' => ['os' => 'linux', 'architecture' => 's390x']])]];
312+
yield 'foreign attestation is not an arm64 child' => [[$amd64, array_replace($arm64, ['platform' => ['os' => 'unknown', 'architecture' => 'unknown']])]];
313+
yield 'ambiguous platform' => [[$amd64, $arm64, $arm64]];
314+
yield 'invalid child digest' => [[$amd64, array_replace($arm64, ['digest' => 'latest'])]];
315+
yield 'nested index is not a platform image' => [[$amd64, array_replace($arm64, ['mediaType' => 'application/vnd.oci.image.index.v1+json'])]];
316+
}
317+
318+
#[DataProvider('invalidTrustIndexes')]
319+
public function test_unverified_platform_selection_fails_before_any_pull_or_trust(array $manifests): void
320+
{
321+
$result = $this->runTrustStep(['INDEX_JSON' => json_encode(['manifests' => $manifests], JSON_THROW_ON_ERROR)]);
322+
self::assertNotSame(0, $result['status']);
323+
self::assertStringNotContainsString('pull --quiet', $result['calls']);
324+
self::assertStringNotContainsString('run --rm', $result['calls']);
325+
}
326+
327+
public function test_mutable_trust_reference_fails_before_registry_or_container_access(): void
328+
{
329+
$result = $this->runTrustStep(['RELEASE_IMAGE' => 'durableworkflow/server:latest']);
330+
self::assertSame(1, $result['status']);
331+
self::assertSame('', $result['calls']);
332+
}
333+
334+
private function runTrustStep(array $environment = []): array
335+
{
336+
$workflow = Yaml::parse(file_get_contents(dirname(__DIR__, 2).'/.github/workflows/release.yml'));
337+
$steps = array_column($workflow['jobs']['publish']['steps'], null, 'name');
338+
$step = $steps['Verify published image trust stores'];
339+
$script = $this->directory.'/trust.sh';
340+
file_put_contents($script, $step['run']);
341+
342+
return $this->runProcess(['bash', $script], array_replace([
343+
'RELEASE_IMAGE_PLATFORMS' => 'linux/amd64,linux/arm64',
344+
'RELEASE_IMAGE' => 'durableworkflow/server@'.self::DIGEST,
345+
'DW_PULL_ATTEMPTS' => $step['env']['DW_PULL_ATTEMPTS'] ?? '3',
346+
'DW_PULL_BACKOFF_SECONDS' => '0',
347+
], $environment));
348+
}
349+
272350
private function context(array $overrides = []): array
273351
{
274352
return array_replace([
@@ -311,6 +389,12 @@ private function runProcess(array $command, array $environment): array
311389
'PATH' => $this->directory.':'.getenv('PATH'), 'DOCKER' => $this->directory.'/docker',
312390
'GITHUB_OUTPUT' => $this->directory.'/output', 'CALL_LOG' => $this->directory.'/calls',
313391
'STATE' => 'valid', 'INDEX_DIGEST' => self::DIGEST,
392+
'INDEX_JSON' => json_encode(['schemaVersion' => 2, 'manifests' => [
393+
['mediaType' => 'application/vnd.oci.image.manifest.v1+json', 'digest' => self::AMD64,
394+
'platform' => ['os' => 'linux', 'architecture' => 'amd64']],
395+
['mediaType' => 'application/vnd.oci.image.manifest.v1+json', 'digest' => self::ARM64,
396+
'platform' => ['os' => 'linux', 'architecture' => 'arm64']],
397+
]], JSON_THROW_ON_ERROR),
314398
'METADATA' => json_encode(['config' => ['Labels' => $labels]], JSON_THROW_ON_ERROR),
315399
'BAD_METADATA' => json_encode(['config' => ['Labels' => $badLabels]], JSON_THROW_ON_ERROR),
316400
], $environment);

‎tests/Unit/ReleaseImagePublishWorkflowContractTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ public function test_dockerfile_checks_every_tls_trust_path_before_it_is_used():
454454
$this->assertLessThan($trust, $exact);
455455
$this->assertLessThan($firstRun, $trust);
456456
$this->assertStringContainsString('--platform "$platform" --network none', $release);
457-
$this->assertStringContainsString('--entrypoint server-verify-trust "$RELEASE_IMAGE"', $release);
457+
$this->assertStringContainsString('--entrypoint server-verify-trust "$platform_image"', $release);
458458

459459
foreach ([
460460
'count /etc/ssl/certs/ca-certificates.crt',

0 commit comments

Comments
 (0)