From 12b7e429ed20b035815aab0abfafd7b9d4fb1cfd Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:31:24 -0400 Subject: [PATCH] Attach VEX statements with the Scout organisation, and allow a run by hand for a published version --- .github/workflows/release.yml | 78 ++----------------- .github/workflows/vex.yml | 120 +++++++++++++++++++++++++++++ test/repo/release-workflow.test.js | 30 +++++++- 3 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/vex.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73512868..d75f75a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,83 +249,17 @@ jobs: readme-filepath: dockerhub-description.md short-description: A self-hosted homelab dashboard you actually want to look at. - # A failed or hung Scout call must not stop the jobs that describe the release. vex: needs: build-and-push if: needs.build-and-push.outputs.dockerhub == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - continue-on-error: true permissions: contents: read - env: - IMAGE: docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard - DIGEST: ${{ needs.build-and-push.outputs.digest }} - VERSION: ${{ needs.build-and-push.outputs.version }} - TAG: ${{ github.ref_name }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Log in to Docker Hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - with: - registry: docker.io - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Every statement must hold for the image it is attached to. A false one - # hides a real vulnerability from users. Keep the musl check. - - name: Prepare the VEX statements - run: | - for arch in amd64 arm64; do - digest=$(docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" | - jq -r --arg a "$arch" '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $a) | .digest') - [ -n "$digest" ] || { echo "no linux/$arch image in $DIGEST"; exit 1; } - cid=$(docker create --platform "linux/$arch" "${IMAGE}@${digest}") - musl=$(docker cp "$cid:/lib/apk/db/installed" - | tar -xO | - awk '/^P:/ { p = substr($0, 3) } /^V:/ && p == "musl" { print substr($0, 3) }') - docker rm "$cid" > /dev/null - if [ -z "$musl" ] || [ "$(printf '%s\n' "$MUSL_FIXED" "$musl" | sort -V | head -n 1)" != "$MUSL_FIXED" ]; then - echo "linux/$arch ships musl '${musl}', older than $MUSL_FIXED. Not attaching." - exit 1 - fi - jq --arg id "https://github.com/SandObserver/Stackyard/releases/tag/${TAG}#vex-${arch}" \ - --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg product "pkg:docker/${IMAGE#docker.io/}@${VERSION}" \ - --arg musl "$musl" --arg fixed "$MUSL_FIXED" \ - '."@id" = $id | .timestamp = $now - | .statements[] |= (.products = [{"@id": $product}] | .timestamp = $now - | .impact_statement = "This image ships musl \($musl). Alpine 3.24 ships the fix in musl \($fixed).")' \ - .github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard-$arch.vex.json" - echo "$digest" > "$RUNNER_TEMP/digest-$arch" - done - env: - MUSL_FIXED: 1.2.6-r3 - - # Keep --referrer. Attaching into the index changes the image digest, and - # the signature no longer matches it. - - name: Attach the VEX statements - run: | - for arch in amd64 arm64; do - docker run --rm --user "$(id -u):$(id -g)" \ - -e HOME=/tmp -e DOCKER_SCOUT_NO_CACHE=true \ - -e DOCKER_SCOUT_HUB_USER -e DOCKER_SCOUT_HUB_PASSWORD \ - -e DOCKER_CONFIG=/docker-config -v "$HOME/.docker:/docker-config:ro" \ - -v "$RUNNER_TEMP:/vex:ro" \ - "$SCOUT_IMAGE" attestation add \ - --file "/vex/stackyard-$arch.vex.json" \ - --predicate-type https://openvex.dev/ns/v0.2.0 \ - --referrer \ - "registry://${IMAGE#docker.io/}@$(cat "$RUNNER_TEMP/digest-$arch")" - done - env: - # Pin by digest. This program holds the Docker Hub token. - SCOUT_IMAGE: docker.io/docker/scout-cli:1.26.0@sha256:ae97866ffbcc23a9bdb28beb3f11b46fa5f8b77380121f74b0eabfb637d63360 - DOCKER_SCOUT_HUB_USER: ${{ vars.DOCKERHUB_USERNAME }} - DOCKER_SCOUT_HUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + uses: ./.github/workflows/vex.yml + with: + digest: ${{ needs.build-and-push.outputs.digest }} + version: ${{ needs.build-and-push.outputs.version }} + secrets: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} # The only gate that reads the published image. It cannot unpublish a bad one, # but it stops the release page and the downstream updates describing it. diff --git a/.github/workflows/vex.yml b/.github/workflows/vex.yml new file mode 100644 index 00000000..7f838c95 --- /dev/null +++ b/.github/workflows/vex.yml @@ -0,0 +1,120 @@ +name: VEX + +on: + workflow_call: + inputs: + digest: + type: string + required: true + version: + type: string + required: true + secrets: + DOCKERHUB_TOKEN: + required: true + workflow_dispatch: + inputs: + version: + description: Published version, for example 1.20.0 + type: string + required: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # A failed or hung Scout call must not fail a published release. + attach: + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: ${{ github.event_name != 'workflow_dispatch' }} + permissions: + contents: read + env: + NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }} + IMAGE: docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard + VERSION: ${{ inputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log in to Docker Hub + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: docker.io + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Resolve the image + run: | + case "$VERSION" in + *[!0-9A-Za-z.-]* | '' | -* ) echo "not a version: '$VERSION'"; exit 1 ;; + esac + if [ -z "$DIGEST" ]; then + DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{json .Manifest}}' | jq -r .digest) + fi + case "$DIGEST" in + sha256:*) ;; + *) echo "no image for ${IMAGE}:${VERSION}"; exit 1 ;; + esac + echo "DIGEST=$DIGEST" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + env: + DIGEST: ${{ inputs.digest }} + + # Every statement must hold for the image it is attached to. A false one + # hides a real vulnerability from users. Keep the musl check. + - name: Prepare the VEX statements + run: | + for arch in amd64 arm64; do + digest=$(docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" | + jq -r --arg a "$arch" '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $a) | .digest') + [ -n "$digest" ] || { echo "no linux/$arch image in $DIGEST"; exit 1; } + cid=$(docker create --platform "linux/$arch" "${IMAGE}@${digest}") + musl=$(docker cp "$cid:/lib/apk/db/installed" - | tar -xO | + awk '/^P:/ { p = substr($0, 3) } /^V:/ && p == "musl" { print substr($0, 3) }') + docker rm "$cid" > /dev/null + if [ -z "$musl" ] || [ "$(printf '%s\n' "$MUSL_FIXED" "$musl" | sort -V | head -n 1)" != "$MUSL_FIXED" ]; then + echo "linux/$arch ships musl '${musl}', older than $MUSL_FIXED. Not attaching." + exit 1 + fi + jq --arg id "https://github.com/SandObserver/Stackyard/releases/tag/${TAG}#vex-${arch}" \ + --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg product "pkg:docker/${IMAGE#docker.io/}@${VERSION}" \ + --arg musl "$musl" --arg fixed "$MUSL_FIXED" \ + '."@id" = $id | .timestamp = $now + | .statements[] |= (.products = [{"@id": $product}] | .timestamp = $now + | .impact_statement = "This image ships musl \($musl). Alpine 3.24 ships the fix in musl \($fixed).")' \ + .github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard-$arch.vex.json" + echo "$digest" > "$RUNNER_TEMP/digest-$arch" + done + env: + MUSL_FIXED: 1.2.6-r3 + + # Keep --referrer. Attaching into the index changes the image digest, and + # the signature no longer matches it. + - name: Attach the VEX statements + run: | + for arch in amd64 arm64; do + docker run --rm --user "$(id -u):$(id -g)" \ + -e HOME=/tmp -e DOCKER_SCOUT_NO_CACHE=true \ + -e DOCKER_SCOUT_HUB_USER -e DOCKER_SCOUT_HUB_PASSWORD \ + -e DOCKER_CONFIG=/docker-config -v "$HOME/.docker:/docker-config:ro" \ + -v "$RUNNER_TEMP:/vex:ro" \ + "$SCOUT_IMAGE" attestation add \ + --org "$NAMESPACE" \ + --file "/vex/stackyard-$arch.vex.json" \ + --predicate-type https://openvex.dev/ns/v0.2.0 \ + --referrer \ + "registry://${IMAGE#docker.io/}@$(cat "$RUNNER_TEMP/digest-$arch")" + done + env: + # Pin by digest. This program holds the Docker Hub token. + SCOUT_IMAGE: docker.io/docker/scout-cli:1.26.0@sha256:ae97866ffbcc23a9bdb28beb3f11b46fa5f8b77380121f74b0eabfb637d63360 + DOCKER_SCOUT_HUB_USER: ${{ vars.DOCKERHUB_USERNAME }} + DOCKER_SCOUT_HUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/test/repo/release-workflow.test.js b/test/repo/release-workflow.test.js index 2284ade6..de086090 100644 --- a/test/repo/release-workflow.test.js +++ b/test/repo/release-workflow.test.js @@ -278,7 +278,9 @@ test('the identity pattern matches the identity the release signs with', () => { } }); -const vexJob = workflow.jobs.vex; +const vexCall = workflow.jobs.vex; +const vexWorkflow = yaml.load(fs.readFileSync(path.join(root, '.github/workflows/vex.yml'), 'utf8')); +const vexJob = vexWorkflow.jobs.attach; const vexStep = name => { const step = vexJob.steps.find(s => s.name === name); assert.ok(step, `the ${name} step is gone`); @@ -286,11 +288,20 @@ const vexStep = name => { }; test('the VEX statements are attached in their own job, which cannot hold up the release', () => { - assert.deepEqual(vexJob.needs, 'build-and-push'); + assert.deepEqual(vexCall.needs, 'build-and-push'); assert.equal(job.outputs.dockerhub, '${{ steps.registries.outputs.dockerhub }}'); - assert.equal(vexJob.if, "needs.build-and-push.outputs.dockerhub == 'true'"); - assert.equal(vexJob['continue-on-error'], true, 'a Scout failure must not fail a published release'); + assert.equal(vexCall.if, "needs.build-and-push.outputs.dockerhub == 'true'"); + assert.equal(vexCall.uses, './.github/workflows/vex.yml'); + assert.equal(vexCall.with.digest, '${{ needs.build-and-push.outputs.digest }}'); + assert.equal(vexCall.with.version, '${{ needs.build-and-push.outputs.version }}'); + assert.deepEqual(Object.keys(vexCall.secrets), ['DOCKERHUB_TOKEN']); + assert.equal( + vexJob['continue-on-error'], + "${{ github.event_name != 'workflow_dispatch' }}", + 'a Scout failure must not fail a published release, and must fail a run by hand', + ); assert.ok(vexJob['timeout-minutes'] <= 10, 'a hung Scout call must end on its own'); + assert.deepEqual(vexCall.permissions, { contents: 'read' }); assert.deepEqual(vexJob.permissions, { contents: 'read' }); for (const [name, other] of Object.entries(workflow.jobs)) { assert.ok(![other.needs].flat().includes('vex'), `${name} must not wait for the VEX job`); @@ -298,6 +309,16 @@ test('the VEX statements are attached in their own job, which cannot hold up the assert.equal(steps.filter(s => /scout/i.test(`${s.name} ${s.uses || ''}`)).length, 0); }); +test('a published version can be given its VEX statements by hand', () => { + assert.deepEqual(Object.keys(vexWorkflow.on.workflow_dispatch.inputs), ['version']); + const resolve = vexStep('Resolve the image'); + assert.ok(vexJob.steps.indexOf(resolve) < vexJob.steps.indexOf(vexStep('Prepare the VEX statements'))); + assert.equal(resolve.env.DIGEST, '${{ inputs.digest }}'); + assert.match(resolve.run, /\*\[!0-9A-Za-z\.-\]\*/, 'the version lands in an image reference'); + assert.match(resolve.run, /imagetools inspect "\$\{IMAGE\}:\$\{VERSION\}"/); + assert.match(resolve.run, /TAG=v\$VERSION/); +}); + test('a statement is attached only to an image that ships the patched musl', () => { const prepare = vexStep('Prepare the VEX statements'); assert.match(prepare.env.MUSL_FIXED, /^\d+\.\d+\.\d+-r\d+$/); @@ -315,6 +336,7 @@ test('the Scout program is pinned by digest and attaches by referrer', () => { assert.ok(prepare < vexJob.steps.indexOf(attach), 'nothing to attach yet'); assert.match(attach.env.SCOUT_IMAGE, /^docker\.io\/docker\/scout-cli:[\d.]+@sha256:[0-9a-f]{64}$/); assert.match(attach.run, /--referrer\b/, 'attaching into the index changes the signed digest'); + assert.match(attach.run, /--org "\$NAMESPACE"/, 'Scout refuses to attach without the organisation'); assert.match(attach.run, /--predicate-type https:\/\/openvex\.dev\/ns\/v0\.2\.0/); assert.match(attach.run, /--file "\/vex\/stackyard-\$arch\.vex\.json"/); assert.match(vexStep('Prepare the VEX statements').run, /"\$RUNNER_TEMP\/stackyard-\$arch\.vex\.json"/);