Skip to main content

Sign and verify a container image

Cilock and cosign are complementary tools for container provenance:

  • Cosign signs the image itself, proves "this image manifest was signed by this identity."
  • Cilock signs evidence about what built the image, proves "this image came from this commit, this build command, with these inputs and this SBOM."

This tutorial wires both together so a verifier can answer both "is this image authentic?" (cosign) and "is this image trustworthy?" (cilock).

What you'll buildโ€‹

Two signing tools, two pieces of evidence, both verifiable independently.

Step 1: Build with attestation evidenceโ€‹

The build step uses cilock-action with the docker and oci attestors so the resulting attestation includes both the buildx metadata and the OCI image layer digests:

- name: build container with attestation
uses: aflock-ai/cilock-action@v1.0.1
with:
step: docker-build
command: |
bash -c "
docker buildx build \
--metadata-file docker-metadata.json \
-t ghcr.io/${{ github.repository }}:${{ github.sha }} \
-o type=docker,dest=image.tar . &&
syft image.tar -o cyclonedx-json=image-bom.cdx.json
"
attestations: environment git github docker oci sbom
cilock-args: --attestor-product-include-glob "{docker-metadata.json,image-bom.cdx.json,image.tar}"

The buildx output is type=docker (a docker save-format tarball with manifest.json at the root), not type=oci (an OCI image-layout tarball with index.json); cilock's OCI attestor parses the former.

Why each attestor:

  • docker parses the buildx metadata file (image digest, build duration, image config)
  • oci unpacks the saved image tarball and digests every layer + manifest
  • sbom parses the CycloneDX SBOM that syft generated against the image

The --attestor-product-include-glob filter ensures cilock captures all three artifact files in the product attestation.

Step 2: Push the image and cosign-sign itโ€‹

After the cilock-attested build, push the image and have cosign sign the manifest itself:

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Push image
run: |
docker buildx build \
-t ghcr.io/${{ github.repository }}:${{ github.sha }} \
--push .

- name: Install cosign
uses: sigstore/cosign-installer@v3

- name: cosign sign image (keyless)
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}:${{ github.sha }}

This is the same cosign keyless-signing pattern the rookery release pipeline uses for its own container image (verifiable from rookery/.github/workflows/release.yml).

The result: one image in GHCR with two parallel pieces of evidence, a cosign signature on the manifest and a cilock attestation in Archivista about how the image was built.

Step 3: Verify both at promotion timeโ€‹

A verifier (release gate, admission controller, or any downstream system) checks both:

# Authenticity: who signed the image manifest?
cosign verify \
--certificate-identity-regexp 'https://github.com/example/myapp/.+' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/example/myapp:abc123

# Trustworthiness: what built the image, with what evidence?
cilock verify \
--policy ./policy-signed.json \
--publickey ./policy-pubkey.pem \
--attestations "$(archivista_fetch_for_image ghcr.io/example/myapp@sha256:... | paste -sd,)" \
--subjects sha256:...

If either fails, the image doesn't promote. The two checks defend against different attacks:

Failure modeCaught by
Image manifest replaced in the registrycosign verify, manifest digest changed
Image was built but missing required SBOMcilock verify, sbom attestation missing from collection
Image was built from the wrong branchcilock verify, Rego rule on the github attestation rejects
Image was built but with unsigned/unpinned actionscilock verify, Layer 1 prevention policy on actionref/refpinned

Either by itself is a meaningful improvement; together they cover both authenticity and trustworthiness.

Subject digest correlationโ€‹

The trick that makes this work: both cosign and cilock end up with digests that identify the same image, just via different paths. Cosign signs the registry OCI manifest digest (what docker buildx imagetools inspect returns). Cilock's oci attestor reads the tarball and records three subjects: the digest of the in-tar manifest.json (Docker-save format), the full tar digest, and the image-config digest (which is also docker images' "IMAGE ID"). To cross-reference, line up imageid from the cilock side with the image-config digest reachable from the registry-stored manifest:

package image.subject_match
import rego.v1

deny contains msg if {
not input.image_digest == input.cosign_subject
msg := sprintf(
"image digest mismatch: cilock has %s, cosign signed %s",
[input.image_digest, input.cosign_subject]
)
}

(Wire this rule into the oci attestation's regopolicies block.)

Patterns to considerโ€‹

PatternUse case
Sign with cosign, attest with cilockThe most common. Image authenticity for any consumer; build provenance for your own gate.
Cosign attest with in-toto predicateCosign supports attaching attestations directly to images via cosign attest. You can use this instead of Archivista for storing cilock attestations as OCI referrers.
BothPush the cilock attestation as an OCI referrer (via cosign attest --predicate=cilock-attestation.json --type=https://aflock.ai/attestations/...) and push to Archivista. Image consumers see the attestation directly; your gate uses Archivista for cross-build queries.

Honest caveatsโ€‹

  • Image SBOMs vs source SBOMs are different. The SBOM generated from image.tar enumerates everything in the image (OS packages, included libraries). The SBOM from bin/myapp only enumerates the app's compile-time dependencies. Pick which question you're answering and document it.
  • Cosign and cilock have different identity models. Cosign verifies a single signature against an identity claim. Cilock verifies attestation collections against a policy with multiple functionaries per step. Both are valid; they answer different questions.
  • Verifying in production typically means baking the policy public key (and the Fulcio/Sigstore trust roots) into the verifier, at admission-controller startup, at gate-job startup, etc. Plan key distribution.

See alsoโ€‹