Skip to main content

Build Provenance

Every publicly distributed Expanso CLI and Expanso Edge artifact — container images and binaries — ships with signed build provenance: a SLSA attestation recording which source commit, workflow, and build run produced that exact file.

Anyone can verify it. You do not need an Expanso account, a GitHub login, or access to Expanso's source repository.

A checksum tells you a file matches the value you were given. Provenance tells you where the file came from: that this exact byte sequence was produced by Expanso's release pipeline, from a named commit, in a build run you can point at — and not rebuilt, patched, or substituted by anyone in between.

What's covered

ArtifactDistributed viaWhere the provenance lives
Container images (Expanso CLI, Expanso Edge)ghcr.io/expanso-io/expanso-cli, ghcr.io/expanso-io/expanso-edgeIn the registry, next to the image
Binaries, every OS and architecture (Expanso CLI, Expanso Edge)get.expanso.ioA *_provenance.intoto.jsonl bundle next to the tarballs

:::note Which releases carry provenance Provenance is attached to release artifacts built after 12 August 2026, and artifacts built from 14 August 2026 onward come from the hardened pipeline described under what verification proves. Anything published before that — including stable v2.1.20 — has no attestation, and there is no bundle to download. Nightly builds carry provenance now; stable releases do from the next stable release onward. :::

Verification policy

Verifying a signature only tells you something signed the artifact. What makes provenance meaningful is checking which identity signed it. Enforce all four of these:

PropertyRequired value
Predicate typehttps://slsa.dev/provenance/v1
Repository ownerhttps://github.com/expanso-io
OIDC issuerhttps://token.actions.githubusercontent.com
Signer workflow — imagesexpanso-io/expanso/.github/workflows/_docker-release.yml
Signer workflow — binariesexpanso-io/expanso/.github/workflows/_release.yml

:::caution Always pin the signer workflow --owner expanso-io on its own accepts an attestation signed by any workflow in the organization. Adding --signer-workflow restricts it to the release pipeline, which is what makes the provenance non-forgeable by other build jobs. The commands below always pass it. :::

Before you start

You need the GitHub CLI (gh 2.x) for gh attestation verify, plus curl and jq. You do not need to run gh auth login — every command below reads a locally downloaded bundle and talks only to the public Sigstore trust root.

Verify a binary

Binaries publish their provenance bundle alongside the tarballs. Download both, then verify:

MODULE=edge # or: cli
CHANNEL=nightly # or: stable
BINARY=expanso-edge # or: expanso-cli

# Look up the current version for that module and channel
VERSION=$(curl -s "https://get.expanso.io/manifests/$MODULE/$CHANNEL.json" | jq -r .version)
BASE="https://get.expanso.io/releases/$MODULE/$CHANNEL/$VERSION"

curl -sSLO "$BASE/${BINARY}_${VERSION}_linux_amd64.tar.gz"
curl -sSLO "$BASE/${BINARY}_${VERSION}_provenance.intoto.jsonl"

gh attestation verify "${BINARY}_${VERSION}_linux_amd64.tar.gz" \
--bundle "${BINARY}_${VERSION}_provenance.intoto.jsonl" \
--owner expanso-io \
--signer-workflow expanso-io/expanso/.github/workflows/_release.yml

A successful verification prints the policy it enforced and the attestation that satisfied it:

Loaded digest sha256:c3f982813ec95e0332b0bc5bbe1ad7e76cb9b41d4ed78597530b3910651ac7fc for file://expanso-edge_v2.1.21-nightly.36.gc0bf2c76_linux_amd64.tar.gz
Loaded 1 attestations from expanso-edge_v2.1.21-nightly.36.gc0bf2c76_provenance.intoto.jsonl

The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/expanso-io
- Subject Alternative Name must match regex: ^https://github.com/expanso-io/expanso/.github/workflows/_release.yml
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com

✓ Verification succeeded!

The following 1 attestation matched the policy criteria

The command exits 0 on success and non-zero on any failure, so it drops straight into a script or an installer.

:::tip One bundle covers every platform A module's bundle lists all of its OS/architecture tarballs as subjects, so the same .intoto.jsonl verifies the darwin_arm64 download as well as the linux_amd64 one. Download it once. :::

Cross-check the checksum

The release manifest carries a SHA-256 for every artifact, which should match the digest gh computed above:

curl -s "$BASE/manifest.json" | jq -r '.artifacts.linux_amd64.sha256'
# c3f982813ec95e0332b0bc5bbe1ad7e76cb9b41d4ed78597530b3910651ac7fc

Verify a container image

Image provenance is stored in the registry itself, so it is fetchable anonymously. Pull the bundle, then verify the image by digest:

REPO=expanso-io/expanso-edge # or: expanso-io/expanso-cli
TAG=nightly
REG="https://ghcr.io/v2/$REPO"

TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:$REPO:pull&service=ghcr.io" | jq -r .token)
AUTH="Authorization: Bearer $TOKEN"
IDX='Accept: application/vnd.oci.image.index.v1+json'
MAN='Accept: application/vnd.oci.image.manifest.v1+json'

# Resolve the tag to an immutable digest
DIGEST=$(curl -sI -H "$AUTH" -H "$IDX" "$REG/manifests/$TAG" \
| tr -d '\r' | grep -i '^docker-content-digest:' | awk '{print $2}')

# The attestation is published under the OCI referrers fallback tag: sha256-<digest>
ATT=$(curl -s -H "$AUTH" -H "$IDX" "$REG/manifests/${DIGEST/:/-}" | jq -r '.manifests[0].digest')
BLOB=$(curl -s -H "$AUTH" -H "$MAN" "$REG/manifests/$ATT" | jq -r '.layers[0].digest')
curl -sL -H "$AUTH" "$REG/blobs/$BLOB" > image-provenance.json

gh attestation verify "oci://ghcr.io/$REPO@$DIGEST" \
--bundle image-provenance.json \
--owner expanso-io \
--signer-workflow expanso-io/expanso/.github/workflows/_docker-release.yml
Loaded 1 attestations from image-provenance.json

The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/expanso-io
- Subject Alternative Name must match regex: ^https://github.com/expanso-io/expanso/.github/workflows/_docker-release.yml
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com

✓ Verification succeeded!

The following 1 attestation matched the policy criteria

Always verify by digest, not by tag. Tags like nightly and latest move; a digest names one immutable image.

:::note Why the manual bundle fetch Registry-native tools normally discover attestations through the OCI referrers API. GHCR does not currently serve referrers for these packages, so the recipe above reads the fallback tag (sha256-<digest>) that the attestation is published under instead. The bundle it returns is a standard Sigstore bundle. :::

Verifying without access to the source repository

gh attestation verify can also look attestations up online, by passing --owner without --bundle. That queries GitHub's attestation store for the source repository — which is private, so anyone outside the organization gets:

Error: HTTP 401: Bad credentials (https://api.github.com/orgs/expanso-io/attestations/sha256:d67cc76c...)

This is expected, and it is why every command on this page passes --bundle. The published bundles are self-contained: each one carries the signed statement and the certificate chain, so verification runs against the public Sigstore trust root and needs no GitHub credentials and no source access. If you see the error above, you used the online lookup by mistake.

Read the provenance yourself

The bundle is a JSON document you can inspect without any verification tooling. Its DSSE payload is a base64-encoded in-toto statement:

jq -r '.dsseEnvelope.payload' image-provenance.json | base64 -d | jq .

The interesting fields, from a real nightly image build:

{
"predicate": {
"buildDefinition": {
"buildType": "https://actions.github.io/buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/heads/main",
"repository": "https://github.com/expanso-io/expanso",
"path": ".github/workflows/nightly-release.yml"
}
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/expanso-io/expanso@refs/heads/main",
"digest": {"gitCommit": "c0bf2c764f8b37832d20f97f19284e45ce2e0c41"}
}
]
},
"runDetails": {
"builder": {"id": "https://github.com/expanso-io/expanso/.github/workflows/_docker-release.yml@refs/heads/main"},
"metadata": {"invocationId": "https://github.com/expanso-io/expanso/actions/runs/31786485060/attempts/1"}
}
}
}

Reading that: the artifact was built from commit c0bf2c76 on main by the release pipeline, and builder.id is the workflow whose signing identity you pinned. The invocationId links to the build run.

The gitCommit is the value to quote in an audit record, and it's the same commit the binary reports at runtime — so you can tie a running node back to a verified build:

$ expanso-edge version --detailed
Version: v2.1.21-nightly.36.gc0bf2c76
Git Commit: c0bf2c764f8b37832d20f97f19284e45ce2e0c41
Build Date: 2026-08-14T09:01:30Z

Signing certificate details

Verification checks the signing certificate for you, but if you're auditing by hand it also records which commit of the workflow did the signing, separately from the source commit:

jq -r '.verificationMaterial.certificate.rawBytes' image-provenance.json \
| base64 -d | openssl x509 -inform DER -noout -text
Certificate extensionOIDMeaning
Build Config URI / Digest…57264.1.18 / .19The entry workflow that was triggered, and the commit it was read at
Build Signer URI / Digest…57264.1.9 / .10The reusable workflow that minted the attestation, and its commit
Source Repository Digest…57264.1.13The commit the artifact was built from

All three digests hold the same commit for Expanso releases, because the entry workflow calls the signing workflow by local path (./.github/workflows/…), which GitHub always resolves at the caller's commit. They would only diverge if the signing workflow were pulled from another repository or pinned to a different ref.

Air-gapped verification

Verification normally fetches the Sigstore trust root over the network. Capture it once while online, then verify offline:

gh attestation trusted-root > trusted_root.jsonl # run once, while online

gh attestation verify "${BINARY}_${VERSION}_linux_amd64.tar.gz" \
--bundle "${BINARY}_${VERSION}_provenance.intoto.jsonl" \
--custom-trusted-root trusted_root.jsonl \
--owner expanso-io \
--signer-workflow expanso-io/expanso/.github/workflows/_release.yml

What verification does and doesn't prove

A successful verification tells you:

  • The artifact was produced by Expanso's release pipeline, on GitHub-hosted infrastructure, from the source commit named in the provenance.
  • It has not been altered since that build — any change to a single byte fails the check.
  • The signing identity belongs to the release workflow, not to the code being built. From 14 August 2026 the identity is issued only to dedicated attestation jobs that run no build code, so a compromised build step cannot mint provenance in the release pipeline's name — the isolation SLSA Build Level 2 asks for.

It does not tell you:

  • That the source code is free of defects or vulnerabilities. Provenance is about origin, not quality — pair it with your own scanning.
  • Anything about an artifact you didn't verify. Verify the specific digest you are about to deploy, not a tag you resolved earlier.
  • Anything about locally built binaries. Only artifacts built by the release pipeline are attested; a binary you built yourself has no provenance.

Next steps