diff --git a/.github/vex/stackyard.vex.json b/.github/vex/stackyard.vex.json new file mode 100644 index 00000000..f8b7297d --- /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/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@{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 04741154..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 @@ -248,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 89597e25..5018c391 100644 --- a/test/repo/release-workflow.test.js +++ b/test/repo/release-workflow.test.js @@ -245,3 +245,67 @@ test('the identity pattern matches the identity the release signs with', () => { assert.match(identity, new RegExp(pattern), `no signature can satisfy ${pattern}`); } }); + +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('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('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', + '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}`); + for (const p of s.products) assert.match(p['@id'], /@\{version\}$/, 'unstamped, it would cover every release'); + } +});