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
16 changes: 16 additions & 0 deletions .github/vex/stackyard.vex.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
79 changes: 79 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions test/repo/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
Loading