From 50355eafbb5e6b0d8b3949f250ed821b4284a222 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:16:05 -0400 Subject: [PATCH] Scan the arm64 release image before publishing it The Trivy gate read a linux/amd64 image built and loaded for that purpose, while the push carried linux/amd64 and linux/arm64, so every release published an arm64 image nothing had scanned. Build arm64 under the QEMU already set up for the job, scan it on the same terms, and assert in the workflow test that every published platform has a build-and-scan pair ahead of the push. --- .github/workflows/release.yml | 44 ++++++++++++++----- CHANGELOG.md | 3 ++ api/test/release-workflow.test.js | 72 ++++++++++++++++++++----------- docs/security.md | 4 +- 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1821758d..c60b98da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,14 +109,14 @@ jobs: type=raw,value=latest,enable=${{ steps.tag.outputs.prerelease == 'false' }} # A multi-platform image is assembled by buildx and never exists locally, - # so scanning what gets pushed means pushing it first. One platform does - # load, and the layer cache carries it into the real build below, so the - # gate costs a cache-warm rebuild rather than a second full build. + # so each published platform is built on its own here and scanned before + # anything is pushed. The layer cache carries both into the real build + # below, so the gate costs a cache-warm rebuild rather than a second full + # build of each. # - # linux/arm64 cannot be loaded on an x86 runner and so is still published - # unscanned. It shares this image's base and package set; what it does not - # share is architecture-specific package versions. - - name: Build for scanning + # Keep both scans. The two platforms share a base and a package set, but + # not architecture-specific package versions. + - name: Build amd64 for scanning uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . @@ -125,14 +125,38 @@ jobs: push: false build-args: | APP_VERSION=${{ steps.meta.outputs.version }} - tags: stackyard:scan + tags: stackyard:scan-amd64 cache-to: type=gha,mode=max cache-from: type=gha - - name: Scan the image + - name: Scan the amd64 image uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: stackyard:scan + image-ref: stackyard:scan-amd64 + format: table + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' + + # Built under the QEMU set up above. Trivy reads the filesystem and does + # not run the image, so emulation is needed for the build only. + - name: Build arm64 for scanning + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/arm64 + load: true + push: false + build-args: | + APP_VERSION=${{ steps.meta.outputs.version }} + tags: stackyard:scan-arm64 + cache-to: type=gha,mode=max + cache-from: type=gha + + - name: Scan the arm64 image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: stackyard:scan-arm64 format: table severity: HIGH,CRITICAL ignore-unfixed: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0916f31b..87468ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- The `linux/arm64` release image is scanned for vulnerabilities before it is + published, the same as `linux/amd64`. + - A stored credential is only reused for the request it was saved for. Changing where a badge or a widget sends it now requires entering it again. diff --git a/api/test/release-workflow.test.js b/api/test/release-workflow.test.js index 58a5ab85..0d492747 100644 --- a/api/test/release-workflow.test.js +++ b/api/test/release-workflow.test.js @@ -44,37 +44,61 @@ test('the supply-chain steps run after the build, in an order that works', () => assert.ok(indexOf('Generate SBOM') < indexOf('Upload SBOM'), 'the SBOM must exist before it is uploaded'); }); -test('the scan gates the push', () => { - const gate = indexOf('Build for scanning'); - assert.ok(gate !== -1, 'the single-platform build the scan reads is gone'); - assert.ok(gate < indexOf('Scan the image'), 'there is nothing to scan yet'); - assert.ok(indexOf('Scan the image') < indexOf('Build and push'), 'a scanned image is one that has not shipped'); - - const built = byName('Build for scanning'); - assert.equal(built.with.push, false, 'the gate must not publish what it is gating'); - assert.equal(built.with.load, true, 'trivy reads it from the local daemon'); - assert.equal(built.with.platforms, 'linux/amd64', 'only one platform can be loaded on the runner'); - assert.equal( - built.with.tags, - byName('Scan the image').with['image-ref'], - 'the scan must read the image this step just built', - ); - assert.match( - String(built.with['build-args']), - /APP_VERSION=\$\{\{ steps\.meta\.outputs\.version \}\}/, - 'a different build-arg would build different layers from the ones pushed', +/* Every platform in the push, and only those, must have a build-and-scan pair + ahead of it. arm64 shipped unscanned for several releases because the scan + read one platform while the push carried two. */ +const publishedPlatforms = String(byName('Build and push').with.platforms) + .split(',') + .map(p => p.trim()) + .filter(Boolean); +const scanBuilds = steps.filter(s => /^Build \S+ for scanning$/.test(s.name || '')); + +test('every published platform is scanned before anything is pushed', () => { + assert.deepEqual( + scanBuilds.map(s => s.with.platforms).sort(), + [...publishedPlatforms].sort(), + 'a platform is published that nothing scanned', ); + + for (const built of scanBuilds) { + const arch = built.with.platforms.replace('linux/', ''); + const scan = byName(`Scan the ${arch} image`); + assert.ok(scan, `${arch} has no scan step`); + assert.ok(indexOf(built.name) < indexOf(scan.name), `${arch}: there is nothing to scan yet`); + assert.ok(indexOf(scan.name) < indexOf('Build and push'), `${arch}: a scanned image is one that has not shipped`); + assert.equal(built.with.push, false, `${arch}: the gate must not publish what it is gating`); + assert.equal(built.with.load, true, `${arch}: trivy reads it from the local daemon`); + assert.equal(built.with.tags, scan.with['image-ref'], `${arch}: the scan must read the image just built`); + assert.match( + String(built.with['build-args']), + /APP_VERSION=\$\{\{ steps\.meta\.outputs\.version \}\}/, + `${arch}: a different build-arg would build different layers from the ones pushed`, + ); + assert.match(String(built.with['cache-to']), /type=gha/, `${arch}: the push step rebuilds this from cache`); + } + assert.match( String(byName('Build and push').with['cache-from']), /type=gha/, - 'without the shared cache the amd64 image is built twice over', + 'without the shared cache every platform is built twice over', ); }); -test('the scan fails the job on a high or critical finding', () => { - const scan = byName('Scan the image'); - assert.equal(scan.with['exit-code'], '1', 'a finding must fail the release, not just print'); - assert.equal(scan.with.severity, 'HIGH,CRITICAL'); +test('a high or critical finding fails the job, on either platform', () => { + for (const built of scanBuilds) { + const arch = built.with.platforms.replace('linux/', ''); + const scan = byName(`Scan the ${arch} image`); + assert.equal(scan.with['exit-code'], '1', `${arch}: a finding must fail the release, not just print`); + assert.equal(scan.with.severity, 'HIGH,CRITICAL', arch); + assert.equal(scan.with['ignore-unfixed'], true, arch); + } +}); + +/* Emulation is set up for the whole job, but arm64 is the step that cannot + build without it. */ +test('QEMU is set up before the arm64 build', () => { + assert.ok(indexOf('Set up QEMU') !== -1, 'arm64 cannot be built on the runner without it'); + assert.ok(indexOf('Set up QEMU') < indexOf('Build arm64 for scanning')); }); test('the image is addressed by digest everywhere after the build', () => { diff --git a/docs/security.md b/docs/security.md index b6ee8d28..02ae4ea4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -190,8 +190,8 @@ which proves only that something signed the image. Each release build also scans the image with Trivy before publishing it and fails on a HIGH or CRITICAL finding that has a fix available, so a flagged image -never reaches a registry. The scan covers `linux/amd64`; `linux/arm64` is built -by emulation and cannot be scanned before it is pushed. +never reaches a registry. Both published platforms are scanned: `linux/amd64` +and `linux/arm64`, the second built under emulation on the runner. The build also produces an SPDX SBOM listing what is inside the image, attached to the run as an artifact and downloadable from its summary page on GitHub.