Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
72 changes: 48 additions & 24 deletions api/test/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading