From 86435288f8027a33d10447afad48586c9c70a65e Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:29:56 -0400 Subject: [PATCH 1/2] Attach a VEX statement for the musl CVE to Docker Hub images Docker Scout reports CVE-2026-107891 against musl 1.2.6-r3, the patched Alpine 3.24 package. The release now attaches an OpenVEX statement to each published platform through Scout's referrer registry, leaving the signed image digest unchanged. --- .github/vex/stackyard.vex.json | 16 +++++++++++ .github/workflows/release.yml | 46 ++++++++++++++++++++++++++++++ test/repo/release-workflow.test.js | 34 ++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .github/vex/stackyard.vex.json diff --git a/.github/vex/stackyard.vex.json b/.github/vex/stackyard.vex.json new file mode 100644 index 00000000..4a61bfe8 --- /dev/null +++ b/.github/vex/stackyard.vex.json @@ -0,0 +1,16 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/SandObserver/Stackyard/blob/main/.github/vex/stackyard.vex.json", + "author": "SandObserver", + "timestamp": "2026-10-25T00:00:00Z", + "version": 1, + "statements": [ + { + "vulnerability": { "name": "CVE-2026-107891" }, + "products": [{ "@id": "pkg:docker/sandobserver/stackyard" }], + "status": "not_affected", + "justification": "vulnerable_code_not_present", + "impact_statement": "Alpine 3.24 ships the fix in musl 1.2.6-r3." + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04741154..86205987 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -229,6 +229,52 @@ jobs: VERSION: ${{ steps.meta.outputs.version }} DIGEST: ${{ steps.build.outputs.digest }} + # Every statement must hold for the image built here. A false one hides a + # real vulnerability from users. + - name: Prepare the VEX statements + if: steps.registries.outputs.dockerhub == 'true' + run: | + jq --arg product "pkg:docker/${NAMESPACE}/stackyard@${VERSION}" \ + '.statements[].products = [{"@id": $product}]' \ + .github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard.vex.json" + env: + NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }} + VERSION: ${{ steps.meta.outputs.version }} + + # Keep referrer on. Attaching into the index changes the image digest, and + # the signature no longer matches it. + - name: Attach the VEX statements to amd64 + if: steps.registries.outputs.dockerhub == 'true' + continue-on-error: true + uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0 + with: + command: attestation-add + image: registry://docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard@${{ steps.build.outputs.digest }} + platform: linux/amd64 + file: ${{ runner.temp }}/stackyard.vex.json + predicate-type: https://openvex.dev/ns/v0.2.0 + referrer: true + dockerhub-user: ${{ vars.DOCKERHUB_USERNAME }} + dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} + registry-write-user: ${{ vars.DOCKERHUB_USERNAME }} + registry-write-password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Attach the VEX statements to arm64 + if: steps.registries.outputs.dockerhub == 'true' + continue-on-error: true + uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0 + with: + command: attestation-add + image: registry://docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard@${{ steps.build.outputs.digest }} + platform: linux/arm64 + file: ${{ runner.temp }}/stackyard.vex.json + predicate-type: https://openvex.dev/ns/v0.2.0 + referrer: true + dockerhub-user: ${{ vars.DOCKERHUB_USERNAME }} + dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} + registry-write-user: ${{ vars.DOCKERHUB_USERNAME }} + registry-write-password: ${{ secrets.DOCKERHUB_TOKEN }} + # After the push, and stable tags only. A pre-release must not rewrite the # page describing `latest`. - name: Generate the Docker Hub description diff --git a/test/repo/release-workflow.test.js b/test/repo/release-workflow.test.js index 89597e25..8ce3c11c 100644 --- a/test/repo/release-workflow.test.js +++ b/test/repo/release-workflow.test.js @@ -245,3 +245,37 @@ test('the identity pattern matches the identity the release signs with', () => { assert.match(identity, new RegExp(pattern), `no signature can satisfy ${pattern}`); } }); + +const vexSteps = steps.filter(s => /^Attach the VEX statements to /.test(s.name || '')); + +test('every published platform gets the VEX statements, without touching the image', () => { + assert.deepEqual(vexSteps.map(s => s.with.platform).sort(), [...publishedPlatforms].sort()); + for (const s of vexSteps) { + assert.ok(indexOf('Prepare the VEX statements') < indexOf(s.name), `${s.name}: nothing to attach yet`); + assert.ok(indexOf('Build and push') < indexOf(s.name), `${s.name}: the image is not pushed yet`); + assert.equal(s.with.command, 'attestation-add'); + assert.equal(s.with.referrer, true, 'attaching into the index changes the signed digest'); + assert.match(s.with.image, /@\$\{\{ steps\.build\.outputs\.digest \}\}$/, `${s.name} should use the build digest`); + assert.equal(s.if, "steps.registries.outputs.dockerhub == 'true'"); + assert.equal(s['continue-on-error'], true, 'a Scout outage must not fail a published release'); + } +}); + +test('the VEX document is one Docker Scout accepts', () => { + const vex = JSON.parse(fs.readFileSync(path.join(root, '.github/vex/stackyard.vex.json'), 'utf8')); + assert.equal(vex['@context'], 'https://openvex.dev/ns/v0.2.0'); + assert.ok(vex.author, 'Scout rejects a document without an author'); + assert.ok(vex.statements.length > 0); + const justifications = [ + 'component_not_present', + 'vulnerable_code_not_present', + 'vulnerable_code_not_in_execute_path', + 'vulnerable_code_cannot_be_controlled_by_adversary', + 'inline_mitigations_already_exist', + ]; + for (const s of vex.statements) { + assert.match(s.vulnerability.name, /^CVE-\d{4}-\d+$/); + assert.equal(s.status, 'not_affected', 'Scout suppresses nothing for any other status'); + assert.ok(justifications.includes(s.justification), `${s.vulnerability.name}: ${s.justification}`); + } +}); From 2a731e157be047deb41fe5d5473a74475dd93c76 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:16:38 -0400 Subject: [PATCH 2/2] Attach the VEX statements from their own job, after checking musl - Move the attach steps into a vex job after build-and-push, with a 10 minute limit and continue-on-error. No release job waits for it. - Read musl from each platform's published image and attach nothing when it is older than 1.2.6-r3. Name the shipped musl in the statement and stamp the document id and timestamp per release. - Run the Scout CLI from docker/scout-cli pinned by digest, attaching by referrer to each platform's manifest digest. - Make the repository document a template whose product names no real image. --- .github/vex/stackyard.vex.json | 4 +- .github/workflows/release.yml | 125 ++++++++++++++++++----------- test/repo/release-workflow.test.js | 54 ++++++++++--- 3 files changed, 123 insertions(+), 60 deletions(-) diff --git a/.github/vex/stackyard.vex.json b/.github/vex/stackyard.vex.json index 4a61bfe8..f8b7297d 100644 --- a/.github/vex/stackyard.vex.json +++ b/.github/vex/stackyard.vex.json @@ -1,13 +1,13 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/SandObserver/Stackyard/blob/main/.github/vex/stackyard.vex.json", + "@id": "https://github.com/SandObserver/Stackyard/releases/tag/{tag}#vex", "author": "SandObserver", "timestamp": "2026-10-25T00:00:00Z", "version": 1, "statements": [ { "vulnerability": { "name": "CVE-2026-107891" }, - "products": [{ "@id": "pkg:docker/sandobserver/stackyard" }], + "products": [{ "@id": "pkg:docker/sandobserver/stackyard@{version}" }], "status": "not_affected", "justification": "vulnerable_code_not_present", "impact_statement": "Alpine 3.24 ships the fix in musl 1.2.6-r3." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86205987..52af701d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,7 @@ jobs: outputs: digest: ${{ steps.build.outputs.digest }} version: ${{ steps.meta.outputs.version }} + dockerhub: ${{ steps.registries.outputs.dockerhub }} permissions: contents: read packages: write @@ -229,52 +230,6 @@ jobs: VERSION: ${{ steps.meta.outputs.version }} DIGEST: ${{ steps.build.outputs.digest }} - # Every statement must hold for the image built here. A false one hides a - # real vulnerability from users. - - name: Prepare the VEX statements - if: steps.registries.outputs.dockerhub == 'true' - run: | - jq --arg product "pkg:docker/${NAMESPACE}/stackyard@${VERSION}" \ - '.statements[].products = [{"@id": $product}]' \ - .github/vex/stackyard.vex.json > "$RUNNER_TEMP/stackyard.vex.json" - env: - NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }} - VERSION: ${{ steps.meta.outputs.version }} - - # Keep referrer on. Attaching into the index changes the image digest, and - # the signature no longer matches it. - - name: Attach the VEX statements to amd64 - if: steps.registries.outputs.dockerhub == 'true' - continue-on-error: true - uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0 - with: - command: attestation-add - image: registry://docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard@${{ steps.build.outputs.digest }} - platform: linux/amd64 - file: ${{ runner.temp }}/stackyard.vex.json - predicate-type: https://openvex.dev/ns/v0.2.0 - referrer: true - dockerhub-user: ${{ vars.DOCKERHUB_USERNAME }} - dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} - registry-write-user: ${{ vars.DOCKERHUB_USERNAME }} - registry-write-password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Attach the VEX statements to arm64 - if: steps.registries.outputs.dockerhub == 'true' - continue-on-error: true - uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0 - with: - command: attestation-add - image: registry://docker.io/${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard@${{ steps.build.outputs.digest }} - platform: linux/arm64 - file: ${{ runner.temp }}/stackyard.vex.json - predicate-type: https://openvex.dev/ns/v0.2.0 - referrer: true - dockerhub-user: ${{ vars.DOCKERHUB_USERNAME }} - dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} - registry-write-user: ${{ vars.DOCKERHUB_USERNAME }} - registry-write-password: ${{ secrets.DOCKERHUB_TOKEN }} - # After the push, and stable tags only. A pre-release must not rewrite the # page describing `latest`. - name: Generate the Docker Hub description @@ -294,6 +249,84 @@ 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.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 }} + # 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. verify-published: diff --git a/test/repo/release-workflow.test.js b/test/repo/release-workflow.test.js index 8ce3c11c..5018c391 100644 --- a/test/repo/release-workflow.test.js +++ b/test/repo/release-workflow.test.js @@ -246,25 +246,54 @@ test('the identity pattern matches the identity the release signs with', () => { } }); -const vexSteps = steps.filter(s => /^Attach the VEX statements to /.test(s.name || '')); +const vexJob = workflow.jobs.vex; +const vexStep = name => { + const step = vexJob.steps.find(s => s.name === name); + assert.ok(step, `the ${name} step is gone`); + return step; +}; -test('every published platform gets the VEX statements, without touching the image', () => { - assert.deepEqual(vexSteps.map(s => s.with.platform).sort(), [...publishedPlatforms].sort()); - for (const s of vexSteps) { - assert.ok(indexOf('Prepare the VEX statements') < indexOf(s.name), `${s.name}: nothing to attach yet`); - assert.ok(indexOf('Build and push') < indexOf(s.name), `${s.name}: the image is not pushed yet`); - assert.equal(s.with.command, 'attestation-add'); - assert.equal(s.with.referrer, true, 'attaching into the index changes the signed digest'); - assert.match(s.with.image, /@\$\{\{ steps\.build\.outputs\.digest \}\}$/, `${s.name} should use the build digest`); - assert.equal(s.if, "steps.registries.outputs.dockerhub == 'true'"); - assert.equal(s['continue-on-error'], true, 'a Scout outage must not fail a published release'); +test('the VEX statements are attached in their own job, which cannot hold up the release', () => { + assert.deepEqual(vexJob.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.ok(vexJob['timeout-minutes'] <= 10, 'a hung Scout call must end on its own'); + 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`); } + assert.equal(steps.filter(s => /scout/i.test(`${s.name} ${s.uses || ''}`)).length, 0); }); -test('the VEX document is one Docker Scout accepts', () => { +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+$/); + for (const arch of publishedPlatforms) assert.match(prepare.run, new RegExp(`\\b${arch.split('/')[1]}\\b`)); + assert.match(prepare.run, /\/lib\/apk\/db\/installed/); + assert.match(prepare.run, /sort -V/); + assert.match(prepare.run, /Not attaching\."\s*exit 1/); + assert.match(prepare.run, /"@id" = \$id \| \.timestamp = \$now/, 'each release needs its own document id and date'); + assert.match(prepare.run, /\.products = \[\{"@id": \$product\}\]/); +}); + +test('the Scout program is pinned by digest and attaches by referrer', () => { + const prepare = vexJob.steps.indexOf(vexStep('Prepare the VEX statements')); + const attach = vexStep('Attach the VEX statements'); + 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, /--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"/); + for (const step of vexJob.steps.filter(s => s.uses)) assert.match(step.uses, /@[0-9a-f]{40}$/, step.uses); +}); + +test('the VEX document is one Docker Scout accepts, and names no real image by itself', () => { const vex = JSON.parse(fs.readFileSync(path.join(root, '.github/vex/stackyard.vex.json'), 'utf8')); assert.equal(vex['@context'], 'https://openvex.dev/ns/v0.2.0'); assert.ok(vex.author, 'Scout rejects a document without an author'); + assert.match(vex['@id'], /\{tag\}/); assert.ok(vex.statements.length > 0); const justifications = [ 'component_not_present', @@ -277,5 +306,6 @@ test('the VEX document is one Docker Scout accepts', () => { assert.match(s.vulnerability.name, /^CVE-\d{4}-\d+$/); assert.equal(s.status, 'not_affected', 'Scout suppresses nothing for any other status'); assert.ok(justifications.includes(s.justification), `${s.vulnerability.name}: ${s.justification}`); + for (const p of s.products) assert.match(p['@id'], /@\{version\}$/, 'unstamped, it would cover every release'); } });