Skip to content

How to verify evidence and attestations

This guide is for a consumer who received a QCOW2 image and the evidence that attest-vm-image produced for it, and needs to confirm the image is the exact one that was validated and, when signed, that the attestations are genuine.

It assumes the evidence already exists. Producing and signing attestations is the operator task in signing.md.

Prerequisites

  • The image file you want to verify, for example disk.qcow2 — the exact bytes, not a rebuilt copy.
  • The evidence for it. You need at least one of:
  • the evidence directory the action wrote (checksums.txt, the evidence files, and, when the run was signed, attestations/*.sigstore.json); or
  • the attestations published to the repository that ran the action — enough on its own for online verification, no local bundle required.
  • sha256sum (GNU coreutils) for the checksum manifest.
  • The GitHub CLI (gh) with gh attestation support, authenticated with gh auth login or a GH_TOKEN. Commands here were tested with gh 2.94.0.
  • jq if you want to extract predicate JSON for a downstream policy engine.
  • For sigstore-keyless, cosign-key, or kms bundles, Cosign v3.1.2. Key-backed verification also needs the signer's public key obtained through an independently trusted channel.

Throughout, <owner>/<repo> is the repository that ran attest-vm-image and published the attestations — the image builder's repository, not meigma/attest-vm-image.

Verify the evidence checksums

checksums.txt covers the input disk and the four pre-manifest unsigned evidence documents, and is compatible with sha256sum -c. This works for every run regardless of signer, and verifying it re-checks the disk digest because the disk is the first line. evidence-manifest.json is written afterward and is deliberately excluded, as are the signed bundles. Its format and exact line order are in reference.md.

The action records each path exactly as it was passed, workspace-relative by default, so run the check from the same working directory the action ran in (usually the workspace root — not from inside evidence/):

sha256sum -c evidence/checksums.txt

Every covered file prints <path>: OK and the command exits 0. A line that prints FAILED means that file changed since the action sealed it; do not trust it. The attestations/ bundles are deliberately excluded from checksums.txt — they carry their own Sigstore verification material (see reference.md).

If the run was unsigned (signer: none), stop here — there are no attestations to verify.

Verify sigstore-keyless bundles

Use this when the producer selected signer: sigstore-keyless. Pin the exact workflow certificate identity; never replace it with a permissive regular expression. <REF> is the full ref present in the signing workflow identity, such as refs/heads/main.

hex="$(sha256sum disk.qcow2 | awk '{print $1}')"
identity='https://github.com/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF'
issuer='https://token.actions.githubusercontent.com'

cosign verify-blob-attestation \
  --bundle evidence/attestations/provenance.sigstore.json \
  --certificate-identity "$identity" \
  --certificate-oidc-issuer "$issuer" \
  --digest "$hex" --digestAlg sha256 \
  --type https://slsa.dev/provenance/v1

cosign verify-blob-attestation \
  --bundle evidence/attestations/sbom.sigstore.json \
  --certificate-identity "$identity" \
  --certificate-oidc-issuer "$issuer" \
  --digest "$hex" --digestAlg sha256 \
  --type https://spdx.dev/Document/v2.3

cosign verify-blob-attestation \
  --bundle evidence/attestations/validation.sigstore.json \
  --certificate-identity "$identity" \
  --certificate-oidc-issuer "$issuer" \
  --digest "$hex" --digestAlg sha256 \
  --type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1

For CycloneDX, replace the SBOM type with https://cyclonedx.org/bom. All three commands must exit 0; Cosign verifies the exact identity and issuer, the signature and public-log inclusion, the predicate type, and the disk digest in the statement subject. A changed image, changed bundle, wrong workflow identity, or wrong issuer must fail.

Verify cosign-key and kms bundles

Use this when the producer selected signer: cosign-key or signer: kms. These bundles are not published and deliberately contain no public transparency-log entry. Verify them against the independently trusted public key supplied by the signer: cosign.pub for the encrypted-key example or kms.pub exported from the configured KMS key.

hex="$(sha256sum disk.qcow2 | awk '{print $1}')"
public_key=cosign.pub # Use kms.pub for signer: kms.

cosign verify-blob-attestation \
  --bundle evidence/attestations/provenance.sigstore.json \
  --key "$public_key" --insecure-ignore-tlog \
  --digest "$hex" --digestAlg sha256 \
  --type https://slsa.dev/provenance/v1

cosign verify-blob-attestation \
  --bundle evidence/attestations/sbom.sigstore.json \
  --key "$public_key" --insecure-ignore-tlog \
  --digest "$hex" --digestAlg sha256 \
  --type https://spdx.dev/Document/v2.3

cosign verify-blob-attestation \
  --bundle evidence/attestations/validation.sigstore.json \
  --key "$public_key" --insecure-ignore-tlog \
  --digest "$hex" --digestAlg sha256 \
  --type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1

For CycloneDX, replace the SBOM type with https://cyclonedx.org/bom. For SPDX, use the version recorded in the bundle if it differs from v2.3.

All three commands must exit 0. --insecure-ignore-tlog disables only the transparency witness that this backend intentionally omits; Cosign still checks the signature against the selected public key, the predicate type, and the disk digest in the statement subject. Treat a changed image, changed bundle, unexpected type, or different public key as a verification failure.

Verify a published attestation online

Use this when the producing repository signed the run (signer: github) and published its attestations. No local bundle is needed; gh recomputes the digest from your local file and matches it against the repository's published attestations.

gh attestation verify disk.qcow2 --repo <owner>/<repo>

To scope to the owner instead of a specific repository, use --owner (--repo pins owner/repo, a tighter identity check; at least one is required):

gh attestation verify disk.qcow2 --owner <owner>

Pass the real image file, never a bare digest — gh recomputes the digest itself, and a bare sha256:<hex> is rejected with failed to open local artifact.

Success is exit code 0. On an interactive terminal gh prints the policy banner and a green Verification succeeded!. In CI, or whenever output is piped or redirected, gh prints nothing on success — rely on the exit code (echo $?) or add --format json:

gh attestation verify disk.qcow2 --repo <owner>/<repo> --format json

The command above verifies the build-provenance attestation, which uses gh's default predicate. To verify the SBOM or validation attestation instead, add --predicate-type; see Select a specific attestation.

Verify from the local bundle files

When you have the attestations/ directory, verify against a bundle on disk with --bundle instead of fetching from the API. The action writes each attestation as a single Sigstore bundle in one JSON object, and gh attestation verify --bundle accepts that shape directly. The bundle filenames and their predicate types are in reference.md.

There are two tiers, and the difference matters.

Trust root from the network

--bundle reads the attestation from the local file but still contacts the Sigstore and GitHub TUF services for the trust root:

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle evidence/attestations/provenance.sigstore.json

With all network egress blocked this fails with no valid Sigstore verifiers could be initialized. For a fully offline check, use the next tier.

Fully air-gapped

Fetch the trust root once on a networked machine:

gh attestation trusted-root > trusted_root.jsonl

Then verify with zero network calls by passing --custom-trusted-root:

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle evidence/attestations/provenance.sigstore.json \
  --custom-trusted-root trusted_root.jsonl

This succeeds even behind a fully blocked network.

Select a specific attestation

Each run produces three attestations with different predicate types. gh's default filter is SLSA build provenance, so the provenance attestation verifies with no extra flag, but the SBOM and validation attestations require a matching --predicate-type — without it verification fails with no attestations found with predicate type: https://slsa.dev/provenance/v1.

Validation attestation (the run's primary claim):

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle evidence/attestations/validation.sigstore.json \
  --predicate-type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1

SBOM attestation (SPDX):

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle evidence/attestations/sbom.sigstore.json \
  --predicate-type https://spdx.dev/Document/v2.3

The SPDX predicate type carries the SBOM's own SPDX version, so it is not a fixed string. If this reports no attestations found with predicate type: <the type you passed> (the bundle holds an SBOM attestation, but under a different SPDX version), read the actual type from the bundle (see Extract the predicate JSON) and match the https://spdx.dev/Document/ prefix rather than pinning v2.3. A CycloneDX SBOM uses https://cyclonedx.org/bom instead. The full mapping is in reference.md.

The same --predicate-type flag also works online (drop --bundle). The offline --bundle form shown here is the tested path for the SBOM and validation attestations; the online form for the custom validation predicate is expected to work but was not live-verified.

Extract the predicate JSON for a policy engine

The recommended way proves the signature first and emits the predicate in one step:

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle evidence/attestations/validation.sigstore.json \
  --predicate-type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1 \
  --format json --jq '.[].verificationResult.statement.predicate'

To read a bundle without verifying its signature — for example to discover its predicateType — decode the DSSE payload with jq. This pure-jq form needs no external base64 tool:

jq -r '.dsseEnvelope.payload | @base64d | fromjson | .predicate' \
  evidence/attestations/validation.sigstore.json

Swap .predicate for .predicateType or .subject as needed. Field-by-field definitions of the validation predicate are in predicate/vm-image-validation-v1.md.

Download attestations for later offline use

If you have only the image and want the bundles for archival, download them from the API (gh attestation download is a public-preview subcommand in gh, so its flags and output shape may change in a future gh release):

gh attestation download disk.qcow2 --repo <owner>/<repo>

This writes ./sha256:<digest>.jsonl (JSON Lines — one bundle per line; on Windows the colon becomes a dash: sha256-<digest>.jsonl). Filter to a single predicate type server-side with --predicate-type:

gh attestation download disk.qcow2 --repo <owner>/<repo> \
  --predicate-type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1

Verify against the downloaded file the same way as a local bundle (still needs the trust root unless you add --custom-trusted-root):

gh attestation verify disk.qcow2 --repo <owner>/<repo> \
  --bundle sha256:<digest>.jsonl \
  --predicate-type https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1

Like verify, download needs the real image file — a bare digest is rejected.

Troubleshooting

  • no attestations found with predicate type: https://slsa.dev/provenance/v1, verifying an SBOM or validation bundle — you omitted --predicate-type; the default filter is provenance. Add the matching type.
  • SBOM verify still reports no attestations found with predicate type: <the type you passed> with --predicate-type — the SPDX version differs. Read the real predicateType from the bundle and match the https://spdx.dev/Document/ prefix.
  • no attestations found with no with predicate type: suffix — this is not a --predicate-type mismatch (the two entries above are). It means there are no attestations at all for that artifact in that repository. Common causes: the run was unsigned (signer: none, or the validation result was fail — a failing result is never signed); signing was configured but the producing run could not publish (for example a plan or visibility restriction — see Publish signed attestations for the plan matrix); or you pointed at the wrong --repo/--owner, or at a rebuilt file whose digest does not match the attested bytes. For diagnosing the producer side, see Troubleshoot a failed run.
  • no valid Sigstore verifiers could be initialized — --bundle alone needs the trust root and your network is blocked. Add --custom-trusted-root (see Fully air-gapped) or restore egress to the Sigstore and GitHub TUF endpoints.
  • failed to open local artifact: open sha256:...: no such file or directory — you passed a bare digest. Pass the real image file; gh recomputes the digest.
  • The command printed nothing but exited 0 — that is success on a non-TTY. Check $? or add --format json.
  • sha256sum -c prints FAILED or No such file or directory — you are in the wrong directory (run from where the recorded paths are relative to) or the file changed. See reference.md.
  • reference.md — bundle filenames, predicate types, and subjects.
  • reference.md — every file the action writes.
  • signing.md — the producer side: how these attestations are made.
  • troubleshooting.md — producer-side diagnosis when a run wrote no attestations to verify (never signed, plan restriction, failed run).
  • how-it-works.md — the evidence model and why a failing result is never signed.