A GitHub Action that inspects a finished QCOW2 VM disk image and produces auditable evidence about what is inside it, then optionally signs that evidence.
Run it immediately after an image build — ideally in the same job — so the exact bytes the builder emitted are the bytes that get inspected and attested. The action is independent of any one builder: it accepts a QCOW2 disk from Distrobuilder, mkosi, Packer, bootc/Image Builder, or anything else.
The action never modifies the input image. It validates the disk structure, inspects the filesystem read-only inside an isolated libguestfs appliance, generates a Software Bill of Materials (SBOM), scans that SBOM for known vulnerabilities, runs contamination checks for state that should not ship in a reusable image, records everything in a versioned validation predicate, and computes immutable digests. When explicitly asked, it signs and publishes attestations.
The resulting evidence lets a later system or a human reviewer establish which exact artifact was evaluated, what software it contained, which policy was applied, what passed or failed, which workflow produced the evidence, and which identity signed it.
The default signer: none mode writes checksums, the SBOM, the vulnerability
and validation reports, the validation predicate, and a versioned evidence
manifest without signing, and needs only contents: read. Once an earlier step
in the job has built your image, add the action:
jobs:
attest:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
# An earlier step in this job builds your image at build/disk.qcow2.
- uses: meigma/attest-vm-image@v1
with:
disk-path: build/disk.qcow2
fail-on-severity: none
signer: noneTo sign and publish attestations, add three job permissions and set
signer: github:
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: meigma/attest-vm-image@v1
with: { disk-path: build/disk.qcow2, signer: github }To create portable bundles with GitHub Actions identity but without GitHub's attestation API, grant only OIDC and select public Sigstore keyless signing:
permissions:
contents: read
id-token: write
steps:
- uses: meigma/attest-vm-image@v1
with: { disk-path: build/disk.qcow2, signer: sigstore-keyless }Keyless signing permanently publishes the repository, workflow, ref, commit, run, and certificate identity to public Sigstore transparency services. Use an encrypted Cosign or KMS key instead when that identity must remain private.
Private repositories that cannot use GitHub's attestation API can produce
portable, offline bundles with an encrypted Cosign key instead. Keep the private
key and password in GitHub Secrets and distribute cosign.pub to verifiers
through an independently trusted channel:
permissions:
contents: read
steps:
- uses: meigma/attest-vm-image@v1
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
with:
disk-path: build/disk.qcow2
signer: cosign-key
signing-key: env://COSIGN_PRIVATE_KEYThe same portable, no-public-log bundle contract is available with signer: kms
for non-exportable AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault
Transit, and OpenBao Transit keys. Authenticate to the provider in a preceding
step and pass only an immutable or versioned KMS URI; see the signing
guide for the allowlisted forms and provider status.
Signing has repository plan and visibility requirements, and a failing result is never signed — see Publish signed attestations. For a complete, runnable workflow, start with the Getting started tutorial. Every input and output is listed in the reference.
- A Linux
x64GitHub-hosted runner (arm64is best-effort); non-Linux platforms and other architectures fail closed. - The action installs
qemu-utilsandlibguestfs-toolsitself at runtime, so the runner must permitapt-getand passwordlesssudo— hostedubuntu-*runners qualify by default. - Outbound network access to
github.comfor the pinnedsyft,grype, and external-signingcosignbinaries, to the Grype vulnerability database, to the GitHub attestation API forsigner: github, and to GitHub OIDC plus the public Sigstore services forsigner: sigstore-keyless, or to the selected provider endpoint forsigner: kms.
The full runner, privilege, and network catalog is in the reference.
- Getting started — tutorial: wire the action into a workflow, produce a folder of evidence, and verify it yourself.
- Publish signed attestations — how-to: publish with GitHub, public Sigstore keyless identity, an encrypted Cosign key, or a KMS key.
- Isolate signing credentials — how-to: sign in a separate job so credentials never share an environment with image parsing.
- Verify evidence and attestations — how-to: verify checksums and published attestations as a downstream consumer.
- Control what fails validation — how-to: tune the vulnerability threshold and the contamination policy.
- Troubleshoot a failed run — how-to: a symptom-first decision path when a run fails.
- Reference — every input, output, evidence file, requirement, and failure mode in one place.
- How attest-vm-image works — explanation: the mental model behind the evidence and the two ways a run can fail.
Tooling is pinned by mise and tasks run through moon.
mise install # provision the pinned toolchain (Node, Python, uv, moon)
moon run root:check # the full CI gate: format-check, lint, test, check-dist, auditUseful project commands:
moon run root:format # prettier --write
moon run root:lint # eslint
moon run root:test # jest
moon run root:package # rebuild dist/
moon run docs:build # build the documentation site strictly
moon run docs:serve # serve the documentation site locallyThe bundled action (dist/index.js) is committed — that is what
action.yml executes. After changing anything under src/, run
moon run root:package and commit the refreshed dist/ in the same change;
CI's check-dist task rebuilds the bundle and fails if the committed copy is
stale.
The audit step runs npm audit at the strict --audit-level=low threshold
through scripts/audit.mjs (allowlist currently empty — any advisory fails the
gate). package.json carries an overrides block forcing @sigstore/sign@^5
and @sigstore/bundle@^5 under @actions/attest, resolving the fixed
@sigstore/core@^4.0.1 (GHSA-jfc7-64v2-mr8c); a unit test asserts the lockfile
resolves the patched majors so a silent regression fails in jest.
See CONTRIBUTING.md and SECURITY.md.
Dual-licensed under MIT or Apache-2.0, at your option. Upstream template code is MIT (LICENSE.upstream).