Sign and verify a container image
CI/lock and cosign are complementary tools for container provenance:
- Cosign signs the image itself, proves "this image manifest was signed by this identity."
- CI/lock 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?" (CI/lock).
What you'll build
Two signing tools, two pieces of evidence, both verifiable independently.
Step 1: Build with attestation evidence
The build step runs CI/lock with the docker and oci attestors so the resulting attestation includes both the buildx metadata and the OCI image layer digests. It runs the plain CLI, installed on PATH outside the checkout as the release promotion gate does: cilock-action v1.0.4 keeps no file list, so a policy that links the SBOM to the image cannot pass on its evidence (what v1.0.4 evidence cannot do).
- name: docker-build
env:
CILOCK_PLATFORM_URL: ${{ vars.CILOCK_PLATFORM_URL }} # your administrator-approved origin
IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}
run: |
cilock run --step docker-build --platform-url "$CILOCK_PLATFORM_URL" \
-a environment,git,github,docker,oci --material-manifest \
--attestor-product-include-glob "{docker-metadata.json,image.tar}" \
-o "$RUNNER_TEMP/docker-build.json" \
-- docker buildx build --metadata-file docker-metadata.json -t "$IMAGE" -o type=docker,dest=image.tar .
- name: docker-sbom
env:
CILOCK_PLATFORM_URL: ${{ vars.CILOCK_PLATFORM_URL }}
run: |
cilock run --step docker-sbom --platform-url "$CILOCK_PLATFORM_URL" \
-a environment,git,github,sbom --material-manifest \
--attestor-product-include-glob "image-bom.cdx.json" \
-o "$RUNNER_TEMP/docker-sbom.json" \
-- syft image.tar -o cyclonedx-json=image-bom.cdx.jsonEach step gets its own signed attestation: docker-build carries the buildx metadata + the OCI image content (via the docker and oci attestors); docker-sbom carries the SBOM with the image tarball as material — so a release-gate policy can verify the SBOM was generated against the image that buildx actually produced.
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); CI/lock's OCI attestor parses the former.
Why each attestor:
dockerparses the buildx metadata file (image digest, build duration, image config)ociunpacks the saved image tarball and digests every layer + manifestsbomparses the CycloneDX SBOM that syft generated against the image
The --attestor-product-include-glob filters keep each step's product attestation to its artifact files, and --material-manifest keeps the file lists the cross-step check compares. An empty --platform-url turns the platform off, so an unset CILOCK_PLATFORM_URL cannot send the evidence to the hosted platform by accident.
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 CI/lock 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 mode | Caught by |
|---|---|
| Image manifest replaced in the registry | cosign verify, manifest digest changed |
| Image was built but missing required SBOM | cilock verify, sbom attestation missing from collection |
| Image was built from the wrong branch | cilock verify, Rego rule on the github attestation rejects |
| Image was built but with unsigned/unpinned actions | cilock 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 CI/lock 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). CI/lock'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 CI/lock 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
| Pattern | Use case |
|---|---|
| Sign with cosign, attest with CI/lock | The most common. Image authenticity for any consumer; build provenance for your own gate. |
| Cosign attest with in-toto predicate | Cosign supports attaching attestations directly to images via cosign attest. You can use this instead of Archivista for storing CI/lock attestations as OCI referrers. |
| Both | Push the CI/lock 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.tarenumerates everything in the image (OS packages, included libraries). The SBOM frombin/myapponly enumerates the app's compile-time dependencies. Pick which question you're answering and document it. - Cosign and CI/lock have different identity models. Cosign verifies a single signature against an identity claim. CI/lock 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
- Release promotion gate, wraps this in a build → verify → promote workflow
- SBOM and SARIF evidence, patterns for the SBOM step here
- Cosign documentation, for the cosign side of the workflow
Reference generated from the product documentation. Match commands and support details to your installed release.