Reference¶
This page is the authoritative catalog of the attest-vm-image interface: its
inputs and outputs, the files it writes, the permissions and runner capabilities
it requires, the built-in contamination policy, the pinned tool versions, and
every failure message it can emit. It only describes; task-oriented instructions
live in the how-to guides linked throughout.
The action is referenced as uses: meigma/attest-vm-image@v1.
Inputs¶
Every input is a string. Only disk-path is required; each other input has a
default or a built-in fallback.
| Input | Required | Default | Allowed values / format |
|---|---|---|---|
disk-path |
Yes | — | Filesystem path to the completed QCOW2 image. |
metadata-path |
No | (unset) | Filesystem path to an Incus metadata tarball. Validated per Metadata archive rules. |
build-manifest-path |
No | (unset) | Filesystem path to a builder manifest file. Digested only; its contents are not parsed. |
output-directory |
No | ./evidence |
Directory the evidence files are written into. |
sbom-format |
No | spdx-json |
spdx-json, cyclonedx-json |
fail-on-severity |
No | high |
critical, high, none |
policy-path |
No | (unset) | Filesystem path to a custom contamination policy JSON file. When unset, the built-in policy applies. |
signer |
No | none |
none, github, sigstore-keyless, cosign-key, kms |
signing-key |
No | (unset) | A readable encrypted key file or env://NAME for cosign-key; an allowlisted KMS URI for kms. Always a reference, never raw key bytes. Required by both. |
github-token |
No | ${{ github.token }} |
GitHub token used by signer: github to push attestations; must carry attestations: write. |
Notes:
- All five
signervalues are implemented. A requested signer never falls back to another backend or to unsigned output. cosign-keyalso requires a non-emptyCOSIGN_PASSWORDenvironment variable. Anenv://NAMEreference must name a non-empty environment variable. Both resolved secret values are masked before any tools run.github-tokenis never read from an ambientGITHUB_TOKENenvironment variable; it comes only from this input (whose default is${{ github.token }}).
KMS URI allowlist¶
| Provider | Accepted form | Live validation status |
|---|---|---|
| AWS KMS | awskms:///arn:PARTITION:kms:REGION:ACCOUNT:key/UUID |
Live field test passed with GitHub OIDC. |
| Google Cloud KMS | gcpkms://projects/P/locations/L/keyRings/R/cryptoKeys/K/versions/N |
URI contract supported; field testing pending. |
| Azure Key Vault | azurekms://VAULT.vault.azure.net/KEY/VERSION |
URI contract supported; field testing pending. |
| HashiCorp Vault | hashivault://KEY |
URI contract supported; field testing pending. |
| OpenBao | openbao://KEY |
URI contract supported; field testing pending. |
AWS aliases and custom endpoints, unversioned Google Cloud or Azure keys, nested
Transit paths, k8s://, and external KMS plugins are rejected. AWS partitions
aws, aws-us-gov, and aws-cn are accepted. Vault requires VAULT_ADDR plus
VAULT_TOKEN; OpenBao requires BAO_ADDR plus BAO_TOKEN.
Input validation¶
Inputs are validated up front, before any tool runs. The first invalid input fails closed with a distinct message:
| Condition | Message |
|---|---|
disk-path empty or unset |
disk-path is required but was not provided. |
sbom-format not in the allowed set |
sbom-format must be one of spdx-json, cyclonedx-json; got "<value>". |
fail-on-severity not in the allowed set |
fail-on-severity must be one of critical, high, none; got "<value>". |
signer not in the allowed set |
signer must be one of none, github, sigstore-keyless, cosign-key, kms; got "<value>". |
signer is cosign-key or kms with no signing-key |
signer "<signer>" requires a signing-key reference, but none was provided. |
signing-key supplied to a signer that does not use it |
signer "<signer>" does not accept signing-key; remove the contradictory input. |
signing-key appears to contain private-key bytes |
signing-key must be a key reference, never raw private-key bytes. |
cosign-key reference is not a file or env://NAME |
signer "cosign-key" requires signing-key to be a readable encrypted key file or env://NAME. |
cosign-key file is missing or unreadable |
signer "cosign-key" signing-key file does not exist or is not readable. |
cosign-key environment variable is empty |
signing-key references environment variable <NAME>, but it is unset or empty. |
COSIGN_PASSWORD is empty |
signer "cosign-key" requires the COSIGN_PASSWORD environment variable for the encrypted key. |
kms reference is not allowlisted |
signer "kms" requires signing-key to be an immutable awskms, gcpkms, or azurekms key-version URI, or a hashivault/openbao Transit key URI. |
| Transit address or token is empty | signer "kms" with <provider> requires ambient <ADDR> and <TOKEN> environment variables. |
| Keyless OIDC request environment is unavailable | signer "sigstore-keyless" requires the job permission id-token: write; the GitHub Actions OIDC request environment is unavailable. |
policy-path set but missing or unreadable |
policy-path "<path>" does not exist or is not readable. |
Custom policy-path files are additionally parsed and structurally validated at
this point; those messages are cataloged under Failure modes.
Outputs¶
The action defines nine outputs. Whether each is set depends on the run outcome, of which there are three:
- Pass — the pipeline completes and the overall
resultispass. - Evidence-complete fail — the pipeline completes but
resultisfail(a vulnerability threshold breach or a failed contamination check). - Thrown error — a stage aborts (fail-closed). No output is set at all, even though earlier evidence files may already exist on disk.
| Output | Format / value | Set when |
|---|---|---|
disk-digest |
sha256:<hex> (lowercase, colon-prefixed) |
Pass; evidence-complete fail |
checksums-path |
Path to checksums.txt |
Pass; evidence-complete fail |
sbom-path |
Path to the SBOM (name depends on sbom-format) |
Pass; evidence-complete fail |
vulnerability-report-path |
Path to vulnerability-report.json |
Pass; evidence-complete fail |
validation-report-path |
Path to validation-report.json |
Pass; evidence-complete fail |
validation-predicate-path |
Path to validation-predicate.json |
Pass; evidence-complete fail |
evidence-manifest-path |
Path to evidence-manifest.json |
Pass; evidence-complete fail |
attestation-bundle-path |
Directory of signed bundles (default evidence/attestations) |
Pass and signer is not none |
attestation-url |
URL of the validation attestation only | Pass and signer is github |
Notes:
- The seven non-attestation outputs are set on both a pass and an evidence-complete fail; none are set on any thrown error.
attestation-bundle-pathandattestation-urlare set only on the pass path with signing. A failing result is never signed, so both remain unset even whensigneris notnone.attestation-urlcarries the validation attestation's URL only. The provenance and SBOM attestation URLs are written to the workflow log, not exposed as outputs.sbom-path: read this output rather than hardcoding a filename. The SBOM issbom.spdx.jsonorsbom.cyclonedx.jsondepending onsbom-format.disk-digestissha256:<hex>. The bare 64-character hex form (nosha256:prefix) is what appears inside the predicate, report, andchecksums.txt.
Permissions¶
Required job permissions by signer. The action cannot grant these; the calling job must declare them.
signer |
contents |
id-token |
attestations |
|---|---|---|---|
none |
read |
— | — |
github |
read |
write |
write |
sigstore-keyless |
read |
write |
— |
cosign-key |
read |
— | — |
kms |
read |
— | — |
Notes:
signer: noneneeds onlycontents: read.signer: githubadditionally needsid-token: write(OIDC signing identity) andattestations: write(push to the attestation API usinggithub-token).- Plan and visibility for
signer: github: public repositories on any plan; private and internal repositories require GitHub Enterprise Cloud; GitHub Enterprise Server is unsupported. The action hard-fails and never downgrades to a different signer (see signing.md and how-it-works.md). - Fork pull requests receive a read-only token and no OIDC token, so
signer: githubandsigner: sigstore-keylesscannot run on a fork pull request. signer: sigstore-keylessneeds no GitHub API write permission. It uses the job OIDC identity with public Sigstore services and leavesattestation-urlunset.signer: cosign-keyneeds no OIDC or attestation API permission. The caller supplies an encrypted private key and password through runner-local state.signer: kmsitself needs no GitHub API permission. A preceding cloud authentication step may requireid-token: write; the action consumes only ambient provider credentials. Vault/OpenBao use their short-lived token environment instead.- The AWS principal needs
kms:DescribeKey,kms:GetPublicKey, andkms:Signon the exact asymmetricSIGN_VERIFYkey ARN. Verification uses the exported public key locally and does not needkms:Verify.
Requirements¶
- Runtime: the action runs on the Node 24 Actions runtime. The committed
dist/executes; consumers need no Node, npm, or build tooling. - Runner OS and architecture: Linux only.
x64is the supported and tested target.arm64is best-effort: GitHub-hostedarm64runners have no KVM, so the guest-inspection tooling falls back to slow software emulation (see how-it-works.md for why). Non-Linux platforms and other architectures fail closed (see Failure modes). Every workflow in this repository usesruns-on: ubuntu-24.04. - Privileged package install: the action installs
qemu-utilsandlibguestfs-toolsitself at runtime withsudo apt-get, so the runner must permitapt-getand passwordlesssudo. GitHub-hostedubuntu-*runners qualify by default. - Kernel readability: the action runs
sudo chmod +r /boot/vmlinuz-*so the guest-inspection tooling can read the host kernel image as the non-root runner user. It fails closed if no readable/boot/vmlinuz-*remains. - Same-job pre-build steps: a step that builds or mounts a QCOW2 with
libguestfs/qemubefore the action in the same job must perform thisapt-get installandchmoditself. The action's own install runs only when the action runs, which is too late for a preceding step. - Network egress: the action requires outbound access to:
github.comrelease assets — to download the pinnedsyft,grype, and, forsigner: sigstore-keyless,cosign-key, orkms,cosignbinaries (see Tool versions).- the Grype vulnerability database — downloaded at scan time.
- the GitHub attestation API — only for
signer: github. - GitHub's Actions OIDC endpoint and the public Sigstore Fulcio, Rekor,
certificate-transparency, and TUF services — only for
signer: sigstore-keyless. - the selected KMS or Transit API endpoint — only for
signer: kms. GRYPE_DB_CACHE_DIR: Grype honors this environment variable to pre-seed the vulnerability database on locked-down or air-gapped runners. It is not an action input; set it as a step- or job-levelenv:value and it passes through to Grype.
Evidence files¶
Evidence is written under output-directory (default ./evidence). Basenames
are fixed except the SBOM, whose name depends on sbom-format.
| File | Written when | Description |
|---|---|---|
checksums.txt |
Evidence stage reached | sha256sum -c-compatible manifest (see below). |
sbom.spdx.json |
sbom-format is spdx-json |
SPDX JSON SBOM, disk digest embedded as the subject. |
sbom.cyclonedx.json |
sbom-format is cyclonedx-json |
CycloneDX JSON SBOM, disk digest embedded as the subject. |
vulnerability-report.json |
Evidence stage reached | The raw Grype JSON report. |
validation-report.json |
Evidence stage reached | Flattened predicate plus incusMetadata.properties. |
validation-predicate.json |
Evidence stage reached | The in-toto statement (subject + predicate). |
evidence-manifest.json |
Complete pass or validation fail | Versioned handoff describing inputs and evidence. |
attestations/provenance.sigstore.json |
Implemented signer and pass | Build-provenance Sigstore bundle. |
attestations/sbom.sigstore.json |
Implemented signer and pass | SBOM attestation Sigstore bundle. |
attestations/validation.sigstore.json |
Implemented signer and pass | Validation attestation Sigstore bundle. |
Exactly one SBOM file is written per run. A completed unsigned run writes six
files: checksums.txt, one SBOM, the vulnerability report, the two validation
documents, and evidence-manifest.json. An evidence-complete fail writes that
same set with result: fail in both the predicate and the evidence manifest. A
signed pass adds the three bundles. A fail-closed abort partway through the
pipeline may leave a partial subset and never sets outputs; a signing abort
occurs before the evidence manifest is written. See
Failure modes and how-it-works.md.
validation-report.json differs from validation-predicate.json in exactly one
way: the report prepends the statement's subject and predicateType and its
incusMetadata carries the raw Incus properties object, whereas the
digest-only predicate keeps incusMetadata to its sha256. Field-by-field
definitions of the predicate live in
predicate/vm-image-validation-v1.md.
checksums.txt¶
Format: compatible with sha256sum -c. One line per covered file — a lowercase
64-character hex digest, two spaces, then the path exactly as it was passed to
the action (workspace-relative by default). The verification procedure is in
verification.md.
Line order (exact):
- The input disk (
disk-path). metadata-path, if provided.build-manifest-path, if provided.- The SBOM file (
sbom.spdx.jsonorsbom.cyclonedx.json). vulnerability-report.jsonvalidation-report.jsonvalidation-predicate.json
The attestations/ bundles and evidence-manifest.json are excluded from
checksums.txt. The bundles are written after checksums.txt is sealed and
carry their own Sigstore verification material. The evidence manifest is written
last, hashes checksums.txt and every listed evidence file from their actual
bytes, and cannot include itself. This preserves the checksum manifest's
existing coverage and exact line order.
Disk re-digest guard: before writing checksums.txt, the action re-hashes
disk-path and compares it to the digest computed during disk validation. Any
difference aborts before checksums.txt is written (see
Failure modes: The input disk ... changed during the run).
evidence-manifest.json¶
The evidence manifest is the single handoff document for a later verifier or
publisher. Read its location from evidence-manifest-path; do not reconstruct
the output layout. Version 1 has this shape:
{
"schemaVersion": "1",
"result": "pass",
"artifacts": {
"disk": {
"path": "build/disk.qcow2",
"sha256": "<lowercase hex>"
},
"metadata": null,
"buildManifest": null
},
"evidence": [
{
"role": "checksums",
"path": "evidence/checksums.txt",
"sha256": "<lowercase hex>",
"mediaType": "text/plain"
}
]
}
Contract:
schemaVersionis the manifest contract version. Version 1 is the string"1".resultexactly matches the validation predicate:passorfail.artifacts.diskis always present.artifacts.metadataandartifacts.buildManifestare objects when the corresponding inputs were supplied andnullotherwise. Each object records the input path and bare lowercase SHA-256.evidenceis ordered by the stable roles below. Every digest is calculated from the actual file bytes while the manifest is written; files are supplied explicitly by the pipeline rather than discovered by scanning the directory.attestationUrlis omitted unless signing completed and returned a URL.- The manifest never lists itself and is not added to
checksums.txt.
| Role | Media type | Present when |
|---|---|---|
checksums |
text/plain |
Every completed run |
sbom |
application/spdx+json or application/vnd.cyclonedx+json |
Every completed run |
vulnerability-report |
application/json |
Every completed run |
validation-report |
application/json |
Every completed run |
validation-predicate |
application/vnd.in-toto+json |
Every completed run |
provenance-attestation |
application/vnd.dev.sigstore.bundle.v0.3+json |
Passing run with successful signing |
sbom-attestation |
application/vnd.dev.sigstore.bundle.v0.3+json |
Passing run with successful signing |
validation-attestation |
application/vnd.dev.sigstore.bundle.v0.3+json |
Passing run with successful signing |
Paths use the same conventions as the rest of the action: input artifact paths
are recorded as supplied, and generated evidence paths are the paths written
under output-directory. The manifest contains no combined Incus or Simple
Streams fingerprint and performs no upload or publication.
Attestation bundles¶
Written only when signer is not none and the result is a pass, under
<output-directory>/attestations/. Each file is a single Sigstore bundle stored
as one JSON object, pretty-printed. Verification commands are in
verification.md.
| File | Predicate type | Subject(s) |
|---|---|---|
provenance.sigstore.json |
https://slsa.dev/provenance/v1 |
The disk; plus the metadata tarball as a second subject when metadata-path is set. |
sbom.sigstore.json |
https://spdx.dev/Document/v<X.Y> (SPDX) or https://cyclonedx.org/bom (CycloneDX) |
The disk only. |
validation.sigstore.json |
https://meigma.github.io/attest-vm-image/predicate/vm-image-validation/v1 |
The disk only. |
Notes:
- The SPDX predicate type is derived at runtime from the SBOM's own
spdxVersionfield (for exampleSPDX-2.3yields.../Document/v2.3), defaulting tov2.3when the field is absent or malformed. Match on thehttps://spdx.dev/Document/prefix rather than pinning an exact version. provenance.sigstore.jsonuses the SLSA build-provenance type, which is the default predicate forgh attestation verify(it verifies without--predicate-type). The other two are non-provenance predicates and must be selected with--predicate-type.- The metadata tarball is a subject of the provenance attestation only, never of the SBOM or validation attestations.
attestation-urlpoints at the validation attestation forsigner: github; it is unset forsigstore-keyless,cosign-key, andkmsbundles.sigstore-keylessbundles use the Sigstore bundle v0.3 media type, contain exactly one public transparency-log entry each, and self-verify against the exact${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}certificate identity andhttps://token.actions.githubusercontent.comissuer before promotion.cosign-keybundles use the Sigstore bundle v0.3 media type, contain no transparency-log entries, and are promoted only after all three signatures, subjects, and predicate types self-verify.kmsbundles have the same no-transparency contract and self-verify against a temporary public-key export. Vault/OpenBao perform a second export after all three signatures and reject a changed public-key fingerprint.- The validation predicate type is an opaque, versioned identifier and is not a live endpoint. Field-by-field definitions: predicate/vm-image-validation-v1.md.
Sign action¶
meigma/attest-vm-image/sign@v1 is the companion action for credential-isolated
signing. It consumes an evidence manifest written by
the main action with signer: none, re-verifies the whole handoff fail-closed,
signs it with the same backends the main action uses, and atomically promotes
the manifest to the signed form described above. It runs no image parsing, and
the disk image does not need to be present in its job. Task-oriented
instructions are in Isolate signing credentials.
Sign action inputs¶
| Input | Required | Default | Allowed values / format |
|---|---|---|---|
evidence-manifest |
No | ./evidence/evidence-manifest.json |
Path to the manifest written by the main action. Its evidence files must sit in the same directory. |
disk-path |
No | (unset) | Optional path to the QCOW2 image; when set, the image is re-hashed against the manifest's recorded digest. |
signer |
Yes | — | github, sigstore-keyless, cosign-key, kms. none is rejected. |
signing-key |
No | (unset) | Identical contract to the main action's signing-key, including the KMS allowlist. |
github-token |
No | ${{ github.token }} |
Identical contract to the main action's github-token. |
Notes:
- Backend behavior, permissions, environment requirements, and the produced attestation bundles are identical to inline signing with the main action; only the entrypoint differs.
- The signing job needs only the evidence directory. Signing operates on the
digests recorded in the manifest, so the multi-gigabyte image never has to
cross the job boundary;
disk-pathexists purely as an optional extra check for callers that have the image present anyway.
Handoff verification¶
Before any signing tool runs, the sign action re-verifies the handoff and fails closed with a distinct message on the first violation:
- The manifest exists, parses, and declares
schemaVersion"1"with a well-formedartifacts.diskdigest. resultispass. A failing result is never signed, exactly as with inline signing.- The manifest is unsigned: it carries no
attestationUrland no*-attestationevidence roles. Re-signing a signed manifest is refused. - The evidence list carries exactly the five core roles, in order, with unique
basenames. Each file is located as
dirname(manifest)/basename(recorded path)— the main action co-locates all evidence with the manifest underoutput-directory— so the handoff survives an artifact upload/download that re-roots the directory. - Every evidence file is re-hashed and must match its recorded digest exactly.
- The
validation-predicatefile must parse as the in-toto validation statement, its subject digest must equal the manifest's recorded disk digest, and its predicateresultmust bepass. - When
disk-pathis set, the image is re-hashed and must match the manifest's recorded disk digest.
On success the manifest is rewritten in place with the three *-attestation
roles appended and attestationUrl recorded when the backend returns one; the
promotion is atomic (temp file plus rename), so a failure at any point leaves
the manifest exactly as it was read. Evidence paths in the promoted manifest are
recorded in their locally-resolved form.
Sign action outputs¶
| Output | Format / value | Set when |
|---|---|---|
attestation-bundle-path |
<manifest directory>/attestations |
Successful signing |
attestation-url |
URL of the validation attestation only | Successful signer: github |
evidence-manifest-path |
The evidence-manifest input path, now signed |
Successful signing |
No output is set on any failure, and a failed run never modifies the manifest or leaves partial bundles behind.
Sign action failure modes¶
Input validation reuses the main action's signer rules and messages (see Input validation), with three sign-only additions, and the handoff verification above emits the following messages:
| Condition | Message |
|---|---|
signer empty or unset |
signer is required but was not provided. |
signer is none |
signer "none" is not valid for the sign action; select one of github, sigstore-keyless, cosign-key, kms, or omit this step entirely. |
signer not in the allowed set |
signer must be one of github, sigstore-keyless, cosign-key, kms; got "<value>". |
| Manifest missing or unreadable | evidence-manifest "<path>" does not exist or is not readable. |
| Manifest is not JSON | evidence-manifest "<path>" is not valid JSON. |
| Unknown schema version or malformed document | evidence-manifest "<path>" is not a schemaVersion "1" evidence manifest. |
Manifest records result: fail |
evidence-manifest "<path>" records result "fail"; a failing result is never signed. |
| Manifest already records an attestation URL | evidence-manifest "<path>" already records an attestation URL; re-signing a signed manifest is refused. |
| Manifest already contains attestation bundles | evidence-manifest "<path>" already contains attestation bundles; re-signing a signed manifest is refused. |
| Evidence roles missing, extra, or out of order | evidence-manifest "<path>" must carry exactly the evidence roles checksums, sbom, vulnerability-report, validation-report, validation-predicate in order; got <roles>. |
| Recorded digest is not 64 lowercase hex | evidence-manifest "<path>" records a malformed sha256 for role "<role>". |
| Two evidence entries share a basename | evidence-manifest "<path>" records duplicate evidence basename "<name>". |
| Evidence file missing next to the manifest | evidence file "<path>" (role "<role>") does not exist or is not readable. |
| Evidence file bytes changed since the manifest | evidence file "<path>" (role "<role>") does not match its recorded digest; the handoff was modified and is refused. |
| SBOM media type is not SPDX or CycloneDX | evidence-manifest "<path>" records unsupported SBOM media type "<value>". |
| Predicate file is not the validation statement | validation predicate "<path>" is not a "<predicate type>" in-toto statement. |
| Statement subject digest mismatches the manifest | validation predicate "<path>" subject digest does not match the manifest's recorded disk digest. |
| Statement predicate records a non-passing result | validation predicate "<path>" records a non-passing result; a failing result is never signed. |
disk-path set but missing or unreadable |
disk-path "<path>" does not exist or is not readable. |
disk-path bytes mismatch the recorded digest |
disk-path "<path>" does not match the manifest's recorded disk digest; refusing to sign. |
Signing-stage failures (Cosign, OIDC, KMS, attestation API) are the same as the main action's and are cataloged under Failure modes.
Metadata archive rules¶
Applies when metadata-path is set. The tarball is validated entry by entry
before extraction; any violation is a fail-closed abort. Compression is
auto-detected.
Required:
- A
metadata.yamlat the archive root. metadata.yamlmust contain the fieldsarchitectureandcreation_date.
Only regular files, directories, symlinks, and hardlinks are kept. Every other condition is rejected with a distinct message (see Failure modes):
- device nodes, FIFOs, and any unrecognized tar entry type
- absolute paths
..parent-directory traversal- symlink targets that escape the extraction root
- hardlink targets that escape the extraction root
- an entry whose parent path component is itself a declared symlink (a symlink-chain escape)
The raw Incus properties object is recorded in validation-report.json only;
validation-predicate.json keeps incusMetadata to its digest.
Built-in contamination policy¶
Policy id builtin/v1. It applies whenever policy-path is unset. A custom
policy-path fully replaces this set (there is no merge); the matcher field
shapes and the procedure for extending the built-in rules are documented in
validation-policy.md. The built-in policy carries no
sha256 in the predicate; a custom policy's file digest is recorded in
policy.sha256.
The block below reproduces builtin/v1 exactly and is a valid custom-policy
starting point.
{
"id": "builtin/v1",
"rules": [
{
"id": "no-runner-credentials",
"title": "GitHub Actions runner registration credentials are absent",
"matcher": {
"type": "path-glob",
"glob": "**/actions-runner/.credentials*"
}
},
{
"id": "no-runner-registration",
"title": "GitHub Actions runner registration state is absent",
"matcher": { "type": "path-glob", "glob": "**/actions-runner/.runner" }
},
{
"id": "no-ssh-private-keys",
"title": "SSH private keys are absent from home directories",
"matcher": {
"type": "path-glob",
"glob": "/{root,home}/**/.ssh/id_{rsa,dsa,ecdsa,ed25519}"
}
},
{
"id": "no-pem-private-keys",
"title": "PEM private-key files are absent from home directories",
"matcher": { "type": "path-glob", "glob": "/{root,home}/**/*.pem" }
},
{
"id": "no-ssh-host-keys",
"title": "Persisted SSH host keys are absent",
"matcher": { "type": "path-glob", "glob": "/etc/ssh/ssh_host_*_key*" }
},
{
"id": "no-machine-id",
"title": "Machine identity is cleared for regeneration",
"matcher": { "type": "non-empty-file", "path": "/etc/machine-id" }
},
{
"id": "no-dbus-machine-id",
"title": "D-Bus machine identity is cleared for regeneration",
"matcher": {
"type": "non-empty-file",
"path": "/var/lib/dbus/machine-id"
}
},
{
"id": "no-cloud-init-instance",
"title": "Cloud-init instance state is absent",
"matcher": { "type": "path-exists", "path": "/var/lib/cloud/instance" }
},
{
"id": "no-cloud-init-instances",
"title": "Cloud-init per-instance data directory is absent",
"matcher": { "type": "path-exists", "path": "/var/lib/cloud/instances" }
},
{
"id": "no-apt-auth-conf",
"title": "APT authentication credentials are absent",
"matcher": { "type": "path-exists", "path": "/etc/apt/auth.conf" }
},
{
"id": "no-apt-auth-conf-d",
"title": "APT auth.conf.d credential fragments are absent",
"matcher": { "type": "path-glob", "glob": "/etc/apt/auth.conf.d/*" }
},
{
"id": "no-netrc-credentials",
"title": "netrc credential files are absent from home directories",
"matcher": { "type": "path-glob", "glob": "/{root,home}/**/.netrc" }
},
{
"id": "no-build-temp-files",
"title": "Build temporary files are absent from /tmp",
"matcher": {
"type": "path-glob",
"glob": "/tmp/**",
"exclude": [
"/tmp/.X11-unix",
"/tmp/.ICE-unix",
"/tmp/.font-unix",
"/tmp/.XIM-unix",
"/tmp/.Test-unix",
"/tmp/systemd-private-*"
]
}
},
{
"id": "no-root-shell-history",
"title": "root's shell history is absent",
"matcher": { "type": "path-exists", "path": "/root/.bash_history" }
},
{
"id": "no-root-password",
"title": "root has no set password in /etc/shadow",
"matcher": {
"type": "content-regex",
"path": "/etc/shadow",
"pattern": "^root:[^*!:]"
}
}
]
}
Each rule evaluates read-only against the mounted guest filesystem and produces
one of three statuses: pass (matcher did not hit), fail (matcher hit), or
skip (matcher could not be evaluated). Only a fail status affects the
overall result; a skip never fails the run.
Tool versions¶
The predicate's tools array records name/version pairs. The array is
informational; its length and order are not guaranteed stable across patch
releases. As produced, the order is syft, grype, the apt-installed
packages, then the action itself.
| Tool | Version | Source | Integrity |
|---|---|---|---|
syft |
1.48.0 |
GitHub Release binary (anchore/syft) | Pinned per-platform SHA-256, verified before extraction or caching. |
grype |
0.116.0 |
GitHub Release binary (anchore/grype) | Pinned per-platform SHA-256, verified before extraction or caching. |
cosign |
3.1.2 |
GitHub Release binary (sigstore/cosign) | Pinned per-platform SHA-256, verified before executable caching; downloaded only for external signing. |
qemu-utils |
Ubuntu archive build | sudo apt-get install |
Unpinned; the installed version is recorded via dpkg-query. |
libguestfs-tools |
Ubuntu archive build | sudo apt-get install |
Unpinned; the installed version is recorded via dpkg-query. |
attest-vm-image |
This action's version | package.json |
Appended last. |
Notes:
syft,grype, andcosignare byte-pinned: a download whose SHA-256 does not match the pin aborts before extraction or caching (see Failure modes).qemu-utilsandlibguestfs-toolscarry no version pin; the action installs whatever the runner's Ubuntu archive provides and records thedpkgpackage names (qemu-utils,libguestfs-tools), not the binary names.
Failure modes¶
The action fails in two distinct ways:
- Fail-closed abort — a stage throws before the evidence is complete. The run fails with the thrown message and no output is set. Evidence on disk may be partial or absent.
- Evidence-complete failure — the pipeline runs to completion but the
overall
resultisfail. All seven standard outputs are set, all evidence files are written, and only then does the run fail. Its message is cataloged at the end of this section.
Signing failures are fail-closed aborts that occur after checksums.txt is
sealed but before evidence-manifest.json is written: the pre-manifest unsigned
evidence exists on disk, but no output is set. The reasoning behind the two
taxonomies is in how-it-works.md; the operational decision
path is in troubleshooting.md.
Manifest hashing or write failures are also fail-closed: the underlying file or
filesystem error is reported, evidence-manifest-path remains unset, and no
other output is exposed. This can occur if a concurrent process removes or
changes access to an evidence file before the handoff is sealed.
Placeholders written as <...> are interpolated at runtime.
Input validation¶
disk-path is required but was not provided.— thedisk-pathinput was empty. Provide the path to the completed QCOW2 image.sbom-format must be one of spdx-json, cyclonedx-json; got "<value>".— invalidsbom-format. Usespdx-jsonorcyclonedx-json.fail-on-severity must be one of critical, high, none; got "<value>".— invalidfail-on-severity. Usecritical,high, ornone.signer must be one of none, github, sigstore-keyless, cosign-key, kms; got "<value>".— invalidsigner. Use one of the listed values.signer "<signer>" requires a signing-key reference, but none was provided.—cosign-keyorkmswas selected withoutsigning-key. Provide asigning-keyreference, or select a signer that does not need one.signer "<signer>" does not accept signing-key; remove the contradictory input.— a key reference was supplied tonone,github, orsigstore-keyless.signing-key must be a key reference, never raw private-key bytes.— the input contained a PEM header or newline. Move the encrypted key into a file or environment secret and pass only its reference.signer "cosign-key" requires signing-key to be a readable encrypted key file or env://NAME.— the reference used an unsupported URI or malformed environment name.signer "cosign-key" signing-key file does not exist or is not readable.— materialize a readable encrypted key file before the action or useenv://NAME.signing-key references environment variable <NAME>, but it is unset or empty.— set the named secret environment variable on the action step.signer "cosign-key" requires the COSIGN_PASSWORD environment variable for the encrypted key.— set the key's password as a secret environment variable on the action step.signer "kms" requires signing-key to be an immutable awskms, gcpkms, or azurekms key-version URI, or a hashivault/openbao Transit key URI.— use one of the exact forms in the KMS URI allowlist. The rejected locator is intentionally omitted from the diagnostic.signer "kms" with <provider> requires ambient <ADDR> and <TOKEN> environment variables.— establish the named Vault/OpenBao address and short-lived token before the action runs.signer "sigstore-keyless" requires the job permission id-token: write; the GitHub Actions OIDC request environment is unavailable.— the job did not grant OIDC permission, or the event is a fork pull request. The action detects this before downloading tools or reading the disk. Grantid-token: writeand use a trusted same-repository event.policy-path "<path>" does not exist or is not readable.— thepolicy-pathfile is missing or unreadable. Point it at a readable file.
Disk validation¶
Disk path "<path>" does not exist.—disk-pathpoints at nothing. Check the path.Disk path "<path>" is not a regular file.— the path is a directory, device, or other non-file. Point at the QCOW2 file.Disk "<path>" is not a QCOW2 image: qemu-img reports format "<format>". Only qcow2 is supported.— the input is not QCOW2. Produce or convert toqcow2.Disk "<path>" has an unexpected backing file "<name>"; backing files are not supported in v1.— the image has a backing chain. Flatten it (for example withqemu-img convert).Disk "<path>" failed the qemu-img integrity check: <n> corruption(s), <n> check error(s), exit code <n>. The image is corrupt.— structural corruption. Rebuild the image.
Filesystem inspection¶
No operating system detected in "<path>"; libguestfs found no root filesystem.— no OS root was found. Verify the image contains an installed OS.Multiple operating systems detected in "<path>" (<roots>); multi-boot images are not supported.— more than one OS root. Provide a single-OS image.Could not read /etc/os-release from "<path>"; the guest filesystem could not be fully inspected.—/etc/os-releaseis missing or unreadable. Ensure the guest ships it.The package database cannot be enumerated for "<path>"; refusing to emit an empty package inventory.— no installed packages were found. Verify the image is a real, package-managed OS.
Metadata archive¶
Unsafe archive entry "<name>": device nodes are not allowed.Unsafe archive entry "<name>": FIFOs are not allowed.Unsafe archive entry "<name>": unsupported file type "<type>".Unsafe archive entry "<name>": absolute paths are not allowed.Unsafe archive entry "<name>": parent-directory ("..") traversal is not allowed.Unsafe archive entry "<name>": a parent path component "<prefix>" is a symlink, which could redirect extraction outside the root.Unsafe archive entry "<name>": symlink target "<target>" escapes the extraction root.Unsafe archive entry "<name>": hardlink target "<target>" escapes the extraction root.Unparseable tar listing line: "<line>".
Cause for all of the above: the metadata tarball contains an unsafe or unparseable entry. Remedy: rebuild the tarball with only regular files, directories, and in-root symlinks/hardlinks.
Archive "<path>" does not contain a metadata.yaml at its root.— add ametadata.yamlat the archive root.metadata.yaml in "<path>" is missing the required field "<field>".— add the named field (architectureorcreation_date) tometadata.yaml.
SBOM generation¶
Syft failed to generate an SBOM (exit code <n>) for "<mountPath>".— Syft exited non-zero (its stderr is appended when present). Inspect the appended output.Syft produced an SBOM with zero packages; refusing to emit an empty component inventory.(SPDX) — no real packages were found. Verify the image is package-managed.Syft produced an SBOM with zero components; refusing to emit an empty component inventory.(CycloneDX) — as above, for CycloneDX output.Could not embed the disk digest into the SPDX SBOM: no package matched the document DESCRIBES relationship.— the generated SPDX document had no described package to stamp. Rebuild with a supported Syft version.
Vulnerability scan¶
A scan error aborts the run with no report written and is distinct from a threshold breach. A threshold breach does not appear here — it completes the evidence and becomes an evidence-complete failure (see below).
Grype vulnerability scan failed (exit code <n>) for "<sbomPath>"; recording no vulnerability verdict.— Grype exited non-zero (its stderr is appended when present).Grype produced no output scanning "<sbomPath>"; recording no vulnerability verdict.— Grype emitted nothing.Grype produced unparseable output scanning "<sbomPath>"; recording no vulnerability verdict.— Grype output was not valid JSON.
Remedy for all three: retry; if it persists, check network access to the Grype vulnerability database (see Requirements).
Contamination policy¶
Emitted when loading a custom policy-path file:
Contamination policy "<path>" is not valid JSON.Contamination policy "<path>" must be a JSON object.Contamination policy "<path>" is missing a string "id".Contamination policy "<path>" is missing a "rules" array.Rule at index <index> in policy "<path>" is not an object.Rule at index <index> in policy "<path>" is missing a string "id".Rule "<id>" in policy "<path>" is missing a string "title".Rule "<id>" in policy "<path>" has no matcher object.Rule "<id>" in policy "<path>" has a matcher missing the string field "<field>".Rule "<id>" in policy "<path>" has a matcher field "exclude" that must be an array of strings.Rule "<id>" in policy "<path>" has an unknown matcher type "<type>".
Cause: the custom policy file is malformed or uses an unknown matcher shape. Remedy: correct the named rule/field against the shapes in validation-policy.md.
Tool acquisition and platform¶
Tool acquisition runs before the disk is validated, so these can precede a disk-path error.
Integrity check failed for <name> <version> (<platform>): expected sha256 <expected>, got <actual>. Refusing to extract or cache the download.— a downloadedsyft,grype, orcosignbinary did not match its pinned digest. Retry (the download may be corrupt); a persistent mismatch is a supply-chain signal.No readable kernel image at /boot/vmlinuz-* after chmod. The libguestfs direct backend supermin appliance must read the host kernel image as the non-root runner user; without it guestmount fails.— no readable host kernel remained. Use a runner whose/boot/vmlinuz-*can be made world-readable (see Requirements).attest-vm-image runs only on Linux runners; detected platform "<platform>". Use an ubuntu-* runner.— non-Linux runner. Use anubuntu-*runner.attest-vm-image supports only x64 and arm64 Linux runners; detected architecture "<arch>".— unsupported architecture. Use anx64(or best-effortarm64) Linux runner.
Checksums¶
The input disk "<path>" changed during the run: expected sha256 <expected>, re-digested <actual>. The image must never be modified; refusing to seal checksums.— the disk file changed between validation and sealing. Ensure no other step writes todisk-pathwhile the action runs.
Signing¶
Signing runs only on a passing result. github publishes remotely;
sigstore-keyless creates local bundles with public Sigstore identity and
transparency; cosign-key and kms create local bundles with no public
transparency entry.
signer: github requires a GitHub token to push attestations to the GitHub attestation API. Provide the github-token input (it defaults to the job's ${{ github.token }} and must carry attestations: write), but it resolved empty.—github-tokenresolved empty. Do not override it with an empty value; the default carries the job token.this repository's plan cannot issue attestations; signer: github requires a public repository or GitHub Enterprise Cloud (GitHub Enterprise Server is unsupported), and the caller must grant permissions id-token: write + attestations: write. Underlying error: <underlying message>— the translated plan/visibility diagnostic. It fires when the attestation-API rejection text matches one offorbidden,not found,not accessible, oradvanced security. Remedy: use a public repository or GitHub Enterprise Cloud, and grant the permissions above.Failed to persist attestation: <... Feature not available ... upgrade the billing plan ...>— the raw, untranslated attestation-API rejection. This billing-plan wording is not matched by the classifier above, so it surfaces unchanged. It indicates the same plan/visibility restriction as the translated diagnostic. Remedy: identical — use a public repository or GitHub Enterprise Cloud.signer "sigstore-keyless" requires GITHUB_SERVER_URL and GITHUB_WORKFLOW_REF to construct its exact certificate identity.— the GitHub Actions workflow identity environment is incomplete. Run the action inside GitHub Actions; no permissive identity fallback is used.attestation bundle directory "<path>" already exists; refusing to overwrite it.— remove or choose a freshoutput-directory; the signer never overwrites an earlier bundle set.Cosign produced an unexpected bundle media type./Cosign bundle has invalid transparency-log metadata./Cosign unexpectedly published transparency-log material for signer "<cosign-key|kms>".— Cosign's output violated the offline bundle contract. Nothing is promoted; retain logs and investigate the binary/configuration before retrying.Cosign keyless bundle is missing its required public transparency-log entry.— keyless signing did not produce exactly one public Rekor entry. No bundle directory is promoted, but an earlier successful entry from the same attempt cannot be rolled back.Cosign bundle is missing its DSSE payload./Cosign bundle payload does not match the intended statement.— the bundle was incomplete or did not carry the exact statement supplied by the action. Nothing is promoted.signer "kms" detected a Vault/OpenBao public-key change during signing; no attestation bundles were published.— the Transit key changed between the pre-signing and post-signing exports. Investigate rotation timing and retry only after pinning the intended public key out of band.Command failed with exit code <n>: cosign <operation> [REDACTED]— Cosign could not fetch or use the key. KMS locator and provider stderr are withheld; inspect the preceding provider-authentication step and provider audit log for missing credentials, permission denial, unsupported key usage, or service reachability.
Evidence-complete failure¶
This is not an abort. All evidence and all seven standard outputs are set, signing is skipped, and the run then fails.
Validation result is "fail"; complete evidence was written to "<output-directory>". Reason: <reasons>.
<reasons> is a ;-joined list drawn from:
vulnerability findings at or above the "<threshold>" threshold<n> contamination check(s) failed (<id>, <id>, ...)
Cause: a vulnerability threshold breach and/or one or more failed
contamination checks. Remedy: fix the image, or — for vulnerabilities — adjust
fail-on-severity per validation-policy.md.