From 0b2eaa0f9a567510e05f97b4e40a27752bfd6b33 Mon Sep 17 00:00:00 2001 From: Ramkumar Chinchani <45800463+rchincha@users.noreply.github.com> Date: Fri, 1 May 2026 00:21:06 -0700 Subject: [PATCH] feat(cosign): add support for cosign bundle (#4023) Signed-off-by: Ramkumar Chinchani --- Makefile | 2 +- pkg/cli/client/client.go | 27 ++++++------- pkg/common/common.go | 11 +++++- pkg/common/common_test.go | 7 ++++ pkg/extensions/sync/referrers.go | 2 +- pkg/meta/parse.go | 2 +- pkg/meta/parse_test.go | 29 ++++++++++++++ pkg/storage/common/common.go | 2 +- pkg/storage/gc/gc.go | 2 +- pkg/storage/storage.go | 2 +- pkg/test/image-utils/images.go | 5 +++ pkg/test/oci-utils/oci_layout.go | 6 +-- test/blackbox/annotations.bats | 67 ++++++++++++++++++++++++-------- test/blackbox/sync.bats | 9 +++-- test/blackbox/sync_cloud.bats | 9 +++-- 15 files changed, 135 insertions(+), 47 deletions(-) diff --git a/Makefile b/Makefile index 84ee1a6d..446c1e67 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOLINTER_VERSION := v2.6.2 NOTATION := $(TOOLSDIR)/bin/notation NOTATION_VERSION := 1.3.2 COSIGN := $(TOOLSDIR)/bin/cosign -COSIGN_VERSION := 2.2.0 +COSIGN_VERSION := 3.0.6 HELM := $(TOOLSDIR)/bin/helm ORAS := $(TOOLSDIR)/bin/oras ORAS_VERSION := 1.2.1 diff --git a/pkg/cli/client/client.go b/pkg/cli/client/client.go index df317f56..8a043827 100644 --- a/pkg/cli/client/client.go +++ b/pkg/cli/client/client.go @@ -546,23 +546,24 @@ func isCosignSigned(ctx context.Context, repo, digestStr string, searchConf Sear return true } - var referrers ispec.Index + for _, artifactType := range []string{common.ArtifactTypeCosign, common.ArtifactTypeCosignBundle} { + var referrers ispec.Index - artifactType := url.QueryEscape(common.ArtifactTypeCosign) - URL = fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s", - searchConf.ServURL, repo, digestStr, artifactType) + URL = fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s", + searchConf.ServURL, repo, digestStr, url.QueryEscape(artifactType)) - _, err = httpClient.makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS, - searchConf.Debug, &referrers, searchConf.ResultWriter) - if err != nil { - return false + _, err = httpClient.makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS, + searchConf.Debug, &referrers, searchConf.ResultWriter) + if err != nil { + continue + } + + if len(referrers.Manifests) > 0 { + return true + } } - if len(referrers.Manifests) == 0 { - return false - } - - return true + return false } func (p *requestsPool) submitJob(job *httpJob) { diff --git a/pkg/common/common.go b/pkg/common/common.go index 20cddccc..f4120ce0 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -30,8 +30,9 @@ const ( // ArtifactTypeNotation is the same value as github.com/notaryproject/notation-go/registry.ArtifactTypeNotation // (assert by internal test). // reason used: to reduce zot minimal binary size (otherwise adds oras.land/oras-go/v2 deps). - ArtifactTypeNotation = "application/vnd.cncf.notary.signature" - ArtifactTypeCosign = "application/vnd.dev.cosign.artifact.sig.v1+json" + ArtifactTypeNotation = "application/vnd.cncf.notary.signature" + ArtifactTypeCosign = "application/vnd.dev.cosign.artifact.sig.v1+json" + ArtifactTypeCosignBundle = "application/vnd.dev.sigstore.bundle.v0.3+json" // CosignSignatureTagSuffix is the suffix used for cosign signature tags (e.g., "sha256-digest.sig"). // Using constant to avoid pulling in cosign dependency. CosignSignatureTagSuffix = "sig" @@ -53,6 +54,12 @@ func IsCosignTag(tag string) bool { return IsCosignSignature(tag) || IsCosignSBOM(tag) } +// IsArtifactTypeCosign returns true if the given artifact type corresponds to a cosign signature, +// covering both the legacy type and the newer sigstore bundle type. +func IsArtifactTypeCosign(artifactType string) bool { + return artifactType == ArtifactTypeCosign || artifactType == ArtifactTypeCosignBundle +} + // RemoveFrom removes matches of item in []. func RemoveFrom(inputSlice []string, item string) []string { var newSlice []string diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go index 2b255032..a09bcf8e 100644 --- a/pkg/common/common_test.go +++ b/pkg/common/common_test.go @@ -61,6 +61,13 @@ func TestCommon(t *testing.T) { So(common.ArtifactTypeNotation, ShouldEqual, notreg.ArtifactTypeNotation) }) + Convey("Test IsArtifactTypeCosign", t, func() { + So(common.IsArtifactTypeCosign(common.ArtifactTypeCosign), ShouldBeTrue) + So(common.IsArtifactTypeCosign(common.ArtifactTypeCosignBundle), ShouldBeTrue) + So(common.IsArtifactTypeCosign(common.ArtifactTypeNotation), ShouldBeFalse) + So(common.IsArtifactTypeCosign("application/example"), ShouldBeFalse) + }) + Convey("Test GetLocalIPs", t, func() { localIPs, err := common.GetLocalIPs() So(err, ShouldBeNil) diff --git a/pkg/extensions/sync/referrers.go b/pkg/extensions/sync/referrers.go index be88d038..9c55257d 100644 --- a/pkg/extensions/sync/referrers.go +++ b/pkg/extensions/sync/referrers.go @@ -26,7 +26,7 @@ func hasSignatureReferrers(refs referrer.ReferrerList) bool { return true } - if desc.ArtifactType == common.ArtifactTypeCosign { + if common.IsArtifactTypeCosign(desc.ArtifactType) { return true } } diff --git a/pkg/meta/parse.go b/pkg/meta/parse.go index 77e9783c..a358bcda 100644 --- a/pkg/meta/parse.go +++ b/pkg/meta/parse.go @@ -439,7 +439,7 @@ func isSignature(reference string, manifestContent ispec.Manifest) (bool, string } // check cosign signature - if manifestArtifactType == zcommon.ArtifactTypeCosign && manifestContent.Subject != nil { + if zcommon.IsArtifactTypeCosign(manifestArtifactType) && manifestContent.Subject != nil { return true, CosignType, manifestContent.Subject.Digest } diff --git a/pkg/meta/parse_test.go b/pkg/meta/parse_test.go index 827e38e1..33ae3e20 100644 --- a/pkg/meta/parse_test.go +++ b/pkg/meta/parse_test.go @@ -556,6 +556,35 @@ func RunParseStorageTests(rootDir string, metaDB mTypes.MetaDB, log log.Logger) So(repos[0].Signatures, ShouldContainKey, missingImageDigest.String()) }) + Convey("Detect cosign bundle signatures by artifact type and subject", func() { + imageStore := local.NewImageStore(rootDir, false, false, + log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil) + + storeController := storage.StoreController{DefaultStore: imageStore} + + signedImage := CreateRandomImage() + err := WriteImageToFileSystem(signedImage, repo, "signed", storeController) + So(err, ShouldBeNil) + + bundleSig := CreateMockCosignBundleSignature(signedImage.DescriptorRef()) + err = WriteImageToFileSystem(bundleSig, repo, "bundle-sig", storeController) + So(err, ShouldBeNil) + + err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck + So(err, ShouldBeNil) + + repos, err := metaDB.GetMultipleRepoMeta(ctx, + func(repoMeta mTypes.RepoMeta) bool { return true }) + So(err, ShouldBeNil) + So(repos, ShouldNotBeEmpty) + + repoMeta := repos[0] + subjectDigest := signedImage.DigestStr() + So(repoMeta.Signatures, ShouldContainKey, subjectDigest) + So(repoMeta.Signatures[subjectDigest], ShouldContainKey, zcommon.CosignSignature) + So(len(repoMeta.Signatures[subjectDigest][zcommon.CosignSignature]), ShouldBeGreaterThan, 0) + }) + Convey("Check statistics after load", func() { imageStore := local.NewImageStore(rootDir, false, false, log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil) diff --git a/pkg/storage/common/common.go b/pkg/storage/common/common.go index a6635537..1b7e0f79 100644 --- a/pkg/storage/common/common.go +++ b/pkg/storage/common/common.go @@ -653,7 +653,7 @@ func IsSignature(descriptor ispec.Descriptor) bool { } // is cosign signature (OCI 1.1 support) - if descriptor.ArtifactType == zcommon.ArtifactTypeCosign { + if zcommon.IsArtifactTypeCosign(descriptor.ArtifactType) { return true } diff --git a/pkg/storage/gc/gc.go b/pkg/storage/gc/gc.go index 47c6a0d0..bd8c971c 100644 --- a/pkg/storage/gc/gc.go +++ b/pkg/storage/gc/gc.go @@ -321,7 +321,7 @@ func (gc GarbageCollect) removeReferrer(repo string, index *ispec.Index, manifes // check if its notation or cosign signature if artifactType == zcommon.ArtifactTypeNotation { signatureType = storage.NotationType - } else if artifactType == zcommon.ArtifactTypeCosign { + } else if zcommon.IsArtifactTypeCosign(artifactType) { signatureType = storage.CosignType } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 471c5482..83316966 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -297,7 +297,7 @@ func CheckIsImageSignature(repoName string, manifestBlob []byte, reference strin } // check cosign signature (OCI 1.1 support) - if manifestArtifactType == zcommon.ArtifactTypeCosign && manifestContent.Subject != nil { + if zcommon.IsArtifactTypeCosign(manifestArtifactType) && manifestContent.Subject != nil { return true, CosignType, manifestContent.Subject.Digest, nil } diff --git a/pkg/test/image-utils/images.go b/pkg/test/image-utils/images.go index 998ba88b..25c1e78e 100644 --- a/pkg/test/image-utils/images.go +++ b/pkg/test/image-utils/images.go @@ -268,6 +268,11 @@ func CreateMockCosignSignature(subject *ispec.Descriptor) Image { ArtifactType(common.ArtifactTypeCosign).Build() } +func CreateMockCosignBundleSignature(subject *ispec.Descriptor) Image { + return CreateImageWith().EmptyLayer().EmptyConfig().Subject(subject). + ArtifactType(common.ArtifactTypeCosignBundle).Build() +} + type BaseImageBuilder struct { layers []Layer diff --git a/pkg/test/oci-utils/oci_layout.go b/pkg/test/oci-utils/oci_layout.go index cedc1dc8..2290e1aa 100644 --- a/pkg/test/oci-utils/oci_layout.go +++ b/pkg/test/oci-utils/oci_layout.go @@ -266,12 +266,12 @@ func (olu BaseOciLayoutUtils) checkCosignSignature(name string, digest godigest. return true } - mediaType := common.ArtifactTypeCosign + mediaTypes := []string{common.ArtifactTypeCosign, common.ArtifactTypeCosignBundle} - referrers, err := imageStore.GetReferrers(name, digest, []string{mediaType}) + referrers, err := imageStore.GetReferrers(name, digest, mediaTypes) if err != nil { olu.Log.Info().Err(err).Str("repository", name).Str("digest", - digest.String()).Str("mediatype", mediaType).Msg("invalid cosign signature") + digest.String()).Interface("mediatypes", mediaTypes).Msg("invalid cosign signature") return false } diff --git a/test/blackbox/annotations.bats b/test/blackbox/annotations.bats index ddc8a4e6..d6b94dfc 100644 --- a/test/blackbox/annotations.bats +++ b/test/blackbox/annotations.bats @@ -21,6 +21,11 @@ function verify_prerequisites { return 1 fi + if [ ! $(command -v cosign) ]; then + echo "you need to install cosign as a prerequisite to running the tests" >&3 + return 1 + fi + return 0 } @@ -132,18 +137,37 @@ function teardown_file() { [ "$status" -eq 0 ] run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-test" [ "$status" -eq 0 ] - run cosign sign --key ${BATS_FILE_TMPDIR}/cosign-sign-test.key localhost:${zot_port}/annotations:latest --yes + run cosign sign --registry-referrers-mode=legacy --key ${BATS_FILE_TMPDIR}/cosign-sign-test.key localhost:${zot_port}/annotations:latest --yes [ "$status" -eq 0 ] run cosign verify --key ${BATS_FILE_TMPDIR}/cosign-sign-test.pub localhost:${zot_port}/annotations:latest [ "$status" -eq 0 ] local sigName=$(echo "${lines[-1]}" | jq '.[].critical.image."docker-manifest-digest"') [[ "$sigName" == *"${digest}"* ]] - tags=( $(oras repo tags --plain-http localhost:${zot_port}/annotations) ) - [ "$status" -eq 0 ] - local sigdes=$(oras manifest fetch --descriptor localhost:${zot_port}/annotations:${tags[1]} | jq .digest | tr -d \") - [ "$status" -eq 0 ] - run oras manifest fetch --plain-http localhost:${zot_port}/annotations@${sigdes} + run oras repo tags --plain-http localhost:${zot_port}/annotations [ "$status" -eq 0 ] + local sigTag="" + for tag in "${lines[@]}"; do + if [[ "${tag}" == *.sig ]]; then + sigTag="${tag}" + break + fi + done + + if [ -n "${sigTag}" ]; then + run oras manifest fetch --plain-http --descriptor localhost:${zot_port}/annotations:${sigTag} + [ "$status" -eq 0 ] + local sigdes=$(echo "${output}" | jq -r .digest) + [ -n "${sigdes}" ] + + run oras manifest fetch --plain-http localhost:${zot_port}/annotations@${sigdes} + [ "$status" -eq 0 ] + else + # Fallback lookup via referrers API for registries where cosign v3 does not expose legacy .sig tags. + run oras discover --plain-http --distribution-spec v1.1-referrers-api --format json localhost:${zot_port}/annotations:latest + [ "$status" -eq 0 ] + local sigRefCount=$(echo "${output}" | jq '(.referrers // .manifests // []) | length') + [ "${sigRefCount}" -gt 0 ] + fi } @test "sign/verify with cosign (only referrers)" { @@ -153,20 +177,33 @@ function teardown_file() { [ $(echo "${lines[-1]}" | jq '.data.ImageList.Results[0].RepoName') = '"annotations"' ] local digest=$(echo "${lines[-1]}" | jq -r '.data.ImageList.Results[0].Manifests[0].Digest') - export COSIGN_OCI_EXPERIMENTAL=1 - export COSIGN_EXPERIMENTAL=1 run cosign initialize [ "$status" -eq 0 ] run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-test-experimental" [ "$status" -eq 0 ] - run cosign sign --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-experimental.key localhost:${zot_port}/annotations:latest --yes + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-experimental.key localhost:${zot_port}/annotations:latest --yes [ "$status" -eq 0 ] run cosign verify --key ${BATS_FILE_TMPDIR}/cosign-sign-test-experimental.pub localhost:${zot_port}/annotations:latest [ "$status" -eq 0 ] local sigName=$(echo "${lines[-1]}" | jq '.[].critical.image."docker-manifest-digest"') [[ "$sigName" == *"${digest}"* ]] - unset COSIGN_OCI_EXPERIMENTAL - unset COSIGN_EXPERIMENTAL +} + +@test "zot reports IsSigned true for cosign referrer signatures" { + zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port` + run curl -X POST -H "Content-Type: application/json" --data '{ "query": "{ ImageList(repo: \"annotations\") { Results { RepoName Tag Manifests {Digest ConfigDigest Size Layers { Size Digest }} Vendor Licenses }}}"}' http://localhost:${zot_port}/v2/_zot/ext/search + [ "$status" -eq 0 ] + [ $(echo "${lines[-1]}" | jq '.data.ImageList.Results[0].RepoName') = '"annotations"' ] + + run cosign initialize + [ "$status" -eq 0 ] + run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-is-signed" + [ "$status" -eq 0 ] + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-is-signed.key --allow-insecure-registry localhost:${zot_port}/annotations:latest --yes + [ "$status" -eq 0 ] + run curl -X POST -H "Content-Type: application/json" --data '{ "query": "{ GlobalSearch(query: \"annotations:latest\") { Images { IsSigned } } }" }' http://localhost:${zot_port}/v2/_zot/ext/search + [ "$status" -eq 0 ] + [ $(echo "${lines[-1]}" | jq '.data.GlobalSearch.Images[0].IsSigned') = 'true' ] } @test "sign/verify with cosign (tag and referrers)" { @@ -176,8 +213,6 @@ function teardown_file() { [ $(echo "${lines[-1]}" | jq '.data.ImageList.Results[0].RepoName') = '"annotations"' ] local digest=$(echo "${lines[-1]}" | jq -r '.data.ImageList.Results[0].Manifests[0].Digest') - export COSIGN_OCI_EXPERIMENTAL=1 - export COSIGN_EXPERIMENTAL=1 run cosign initialize [ "$status" -eq 0 ] @@ -188,7 +223,7 @@ function teardown_file() { run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-1" [ "$status" -eq 0 ] - run cosign sign --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-1.key localhost:${zot_port}/annotations:latest --yes + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-1.key localhost:${zot_port}/annotations:latest --yes [ "$status" -eq 0 ] run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-test-tag-2" @@ -211,15 +246,13 @@ function teardown_file() { run cosign generate-key-pair --output-key-prefix "${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-2" [ "$status" -eq 0 ] - run cosign sign --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-2.key localhost:${zot_port}/annotations:latest --yes + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-2.key localhost:${zot_port}/annotations:latest --yes [ "$status" -eq 0 ] run cosign verify --key ${BATS_FILE_TMPDIR}/cosign-sign-test-referrers-2.pub localhost:${zot_port}/annotations:latest [ "$status" -eq 0 ] local sigName=$(echo "${lines[-1]}" | jq '.[].critical.image."docker-manifest-digest"') [[ "$sigName" == *"${digest}"* ]] - unset COSIGN_OCI_EXPERIMENTAL - unset COSIGN_EXPERIMENTAL } @test "sign/verify with notation" { diff --git a/test/blackbox/sync.bats b/test/blackbox/sync.bats index c4183d8b..9d5d3395 100644 --- a/test/blackbox/sync.bats +++ b/test/blackbox/sync.bats @@ -17,13 +17,16 @@ function verify_prerequisites() { return 1 fi + if [ ! $(command -v cosign) ]; then + echo "you need to install cosign as a prerequisite to running the tests" >&3 + return 1 + fi + return 0 } function setup_file() { export COSIGN_PASSWORD="" - export COSIGN_OCI_EXPERIMENTAL=1 - export COSIGN_EXPERIMENTAL=1 # Verify prerequisites are available if ! $(verify_prerequisites); then @@ -275,7 +278,7 @@ function teardown_file() { [ "$status" -eq 0 ] run cosign sign --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes [ "$status" -eq 0 ] - run cosign sign --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes [ "$status" -eq 0 ] run cosign verify --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.pub localhost:${zot_port3}/golang:1.20 [ "$status" -eq 0 ] diff --git a/test/blackbox/sync_cloud.bats b/test/blackbox/sync_cloud.bats index 4d6e5d4c..119b3253 100644 --- a/test/blackbox/sync_cloud.bats +++ b/test/blackbox/sync_cloud.bats @@ -17,13 +17,16 @@ function verify_prerequisites() { return 1 fi + if [ ! $(command -v cosign) ]; then + echo "you need to install cosign as a prerequisite to running the tests" >&3 + return 1 + fi + return 0 } function setup_file() { export COSIGN_PASSWORD="" - export COSIGN_OCI_EXPERIMENTAL=1 - export COSIGN_EXPERIMENTAL=1 # Verify prerequisites are available if ! $(verify_prerequisites); then @@ -302,7 +305,7 @@ function teardown_file() { [ "$status" -eq 0 ] run cosign sign --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes [ "$status" -eq 0 ] - run cosign sign --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes + run env COSIGN_EXPERIMENTAL=1 cosign sign --new-bundle-format=true --registry-referrers-mode=oci-1-1 --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.key localhost:${zot_port3}/golang:1.20 --yes [ "$status" -eq 0 ] run cosign verify --key ${BATS_FILE_TMPDIR}/cosign-sign-sync-test.pub localhost:${zot_port3}/golang:1.20 [ "$status" -eq 0 ]